404 lines
18 KiB
Go
404 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
"github.com/novox/mesh-controller/internal/secrets"
|
|
)
|
|
|
|
// The vault's verbs, reached from the controller's (novox/hq ADR 0349 clauses 8 to 10; design 57's V2, issue 675).
|
|
//
|
|
// **Each module is filled the old way or the new way, by its form** (ADR 0349 clause 17, the release lead's
|
|
// decision of 2026-10-14 on issue 675). `secret accept`, `secret ask` (and `accept --at-desk`), `secret issued`
|
|
// and `secret rotate` go to the vault only for a module whose secrets are filled the new way — its machine asks
|
|
// the vault for them — and keep today's path, the controller's store and the declaration it seals, for every other
|
|
// module. Forwarding a module still filled the old way would report success and change nothing on its machine:
|
|
// the vault hands it nothing, and its machine never asks.
|
|
//
|
|
// **What the controller carries, it cannot open; and it carries only what is given at its own terminal.** The vault
|
|
// takes a value given at this terminal in two steps of one verb: the first names the secret and answers a key the
|
|
// vault made for that one call; the second carries the value sealed here to it (clause 8). A value typed at a desk
|
|
// or issued by a provider's software the controller does not carry at all (issue 723): it asks the vault once, and
|
|
// the vault asks the desk's prompt or the provider's tool itself, in its own name on its own bus account (ADR 0259
|
|
// §8's runs-as), so the prompt and the tool know the vault asks and the value opens there alone (clauses 9 and 10).
|
|
|
|
// vaultSwitch says whether a module's secrets are filled the new way, or why that could not be read.
|
|
type vaultSwitch func(module string) (bool, error)
|
|
|
|
// secretsOfTheVault says whether a module's secrets are filled the new way: the switch the controller's secret
|
|
// verbs turn on. **A module marked moved** (design 57 V4, issue 647): where it is handed to the provisioner, its
|
|
// machine's client fills its `${secret:}` and its secret files from the vault, and the controller makes and seals
|
|
// none of them (catalogue handed.go), so a value given, asked for, issued or rotated goes to the vault. Every
|
|
// other module keeps today's path. A catalogue that cannot be read is an error, never today's path by default: a
|
|
// value kept where the module's machine no longer looks would be reported kept and change nothing.
|
|
func secretsOfTheVault(module string) (bool, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), vaultWithin)
|
|
defer cancel()
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("whether %s's secrets are the vault's cannot be read: %w", module, err)
|
|
}
|
|
defer open.Close()
|
|
shelf, err := open.inventory.Catalogue(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("whether %s's secrets are the vault's cannot be read: %w", module, err)
|
|
}
|
|
return movedOn(shelf, module), nil
|
|
}
|
|
|
|
// movedOn is whether the catalogue marks the module moved: its secrets are the vault's.
|
|
func movedOn(shelf map[string]catalogue.Manifest, module string) bool { return shelf[module].Moved }
|
|
|
|
// filledTheNewWay is the switch as the four commands read it — accept, secret-ask (and accept --at-desk), issued and
|
|
// rotate, each at its own call site: secretsOfTheVault. A test replaces it to prove that each call site sends a
|
|
// module filled the old way down today's path and one filled the new way to the vault (the review of issue 675).
|
|
var filledTheNewWay vaultSwitch = secretsOfTheVault
|
|
|
|
// switchOf is a switch that cannot fail, as a test writes one.
|
|
func switchOf(f func(module string) bool) vaultSwitch {
|
|
return func(module string) (bool, error) { return f(module), nil }
|
|
}
|
|
|
|
// offeringModuleOn is the module on the provider's machine that offers the provision: the one whose secrets hold
|
|
// the provision's one credential, and so the one that decides the path of `accept --provider`. "" when no module,
|
|
// or more than one, on that machine offers it: today's path then, which says what is wrong.
|
|
func offeringModuleOn(ctx context.Context, provider, provision string) (string, error) {
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer open.Close()
|
|
shelf, err := open.inventory.Catalogue(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
assigned, err := open.inventory.Assigned(ctx, provider)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return offeredOn(shelf, assigned, provision), nil
|
|
}
|
|
|
|
// offeredOn is the one module of those assigned that offers the provision, or "" for none or several.
|
|
func offeredOn(shelf map[string]catalogue.Manifest, assigned []string, provision string) string {
|
|
found := ""
|
|
for _, module := range assigned {
|
|
if slices.Contains(shelf[module].Offers(), provision) {
|
|
if found != "" {
|
|
return ""
|
|
}
|
|
found = module
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// bySwitch runs the vault's path for a module whose secrets are filled the new way, and today's for every other.
|
|
func bySwitch(module string, ofTheVault vaultSwitch, today, vault func() error) error {
|
|
if ofTheVault == nil {
|
|
return today()
|
|
}
|
|
ours, err := ofTheVault(module)
|
|
if err != nil {
|
|
return fmt.Errorf("nothing was done: %w", err)
|
|
}
|
|
if ours {
|
|
return vault()
|
|
}
|
|
return today()
|
|
}
|
|
|
|
// vaultAsker asks one of the vault's verbs in the controller's name and answers its result, or its refusal.
|
|
type vaultAsker func(verb string, args map[string]any) (json.RawMessage, error)
|
|
|
|
// vaultWithin is how long the vault is given to answer one step.
|
|
const vaultWithin = 20 * time.Second
|
|
|
|
// vaultAskWithin is how long the vault is given to answer `secret-ask`, which waits for the operator at the desk:
|
|
// its prompt's time (mesh-vault's DeskPromptFor, twenty seconds) and the bus's, under the thirty seconds the caller
|
|
// of this verb waits.
|
|
const vaultAskWithin = 28 * time.Second
|
|
|
|
// theVault asks the vault's seat on this controller's bus, on the subject that names the controller.
|
|
func theVault(ctx context.Context) vaultAsker {
|
|
return func(verb string, args map[string]any) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
err := onTheBus(func(conn *nats.Conn) error {
|
|
within := vaultWithin
|
|
if verb == "secret-ask" {
|
|
within = vaultAskWithin
|
|
}
|
|
answer, err := link.AskMeshSeatToolAs(ctx, conn, broker.ControllerName, "mesh-vault", verb, args, within)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if answer.Error != "" {
|
|
return errors.New(answer.Error)
|
|
}
|
|
result = answer.Result
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
// vaultOpened is a first step's answer: the call, the key made for it, the secret's name in the vault, and who
|
|
// asked as the vault recorded it. Never a value.
|
|
type vaultOpened struct {
|
|
Call string `json:"call"`
|
|
SealTo string `json:"seal_to"`
|
|
Secret string `json:"secret"`
|
|
AskedBy string `json:"asked_by"`
|
|
}
|
|
|
|
// vaultChanged is what the vault kept: never the value.
|
|
type vaultChanged struct {
|
|
Name string `json:"name"`
|
|
Generation int `json:"generation"`
|
|
By string `json:"by"`
|
|
Unchanged bool `json:"unchanged"`
|
|
// Each is, for a provider whose consumers keep a login each, each consumer's own login remade (issue 767).
|
|
Each []vaultChanged `json:"each,omitempty"`
|
|
}
|
|
|
|
func openVaultCall(ask vaultAsker, verb string, args map[string]any) (vaultOpened, error) {
|
|
raw, err := ask(verb, args)
|
|
if err != nil {
|
|
return vaultOpened{}, fmt.Errorf("the vault did not open the call: %w", err)
|
|
}
|
|
var o vaultOpened
|
|
if err := json.Unmarshal(raw, &o); err != nil || o.Call == "" || o.SealTo == "" {
|
|
return vaultOpened{}, errors.New("the vault answered no call and no key to seal to")
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
func endVaultCall(ask vaultAsker, verb string, args map[string]any) (vaultChanged, error) {
|
|
raw, err := ask(verb, args)
|
|
if err != nil {
|
|
return vaultChanged{}, err
|
|
}
|
|
var c vaultChanged
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
return vaultChanged{}, errors.New("the vault answered something that is not what it kept")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// ownSecretArgs names a module's own secret on one machine, as the vault's verbs take it.
|
|
func ownSecretArgs(node, module, name string) map[string]any {
|
|
return map[string]any{"node": node, "module": module, "name": name}
|
|
}
|
|
|
|
// provisionArgs names the one credential of a provider assignment and provision.
|
|
func provisionArgs(provider, provision string) map[string]any {
|
|
return map[string]any{"provider": provider, "provision": provision}
|
|
}
|
|
|
|
// acceptIntoVault is `secret accept` for a module filled the new way: the value read at this terminal is sealed
|
|
// here to the key the vault made for this call, and only the sealed box crosses the bus (ADR 0349 clause 8).
|
|
func acceptIntoVault(ask vaultAsker, target map[string]any, value, by string) (string, error) {
|
|
first := map[string]any{"by": by}
|
|
for k, v := range target {
|
|
first[k] = v
|
|
}
|
|
opened, err := openVaultCall(ask, "accept", first)
|
|
if err != nil {
|
|
return "", fmt.Errorf("nothing was read or sealed: %w", err)
|
|
}
|
|
sealed, err := secrets.Seal(opened.SealTo, []byte(value))
|
|
value = ""
|
|
if err != nil {
|
|
return "", fmt.Errorf("the vault's key for this call is not a sealing key; nothing was sent: %w", err)
|
|
}
|
|
changed, err := endVaultCall(ask, "accept", map[string]any{"call": opened.Call, "sealed": sealed})
|
|
if err != nil {
|
|
return "", fmt.Errorf("the vault did not take it: %w", err)
|
|
}
|
|
if changed.Unchanged {
|
|
return fmt.Sprintf("the vault already holds this value as %s (generation %d); nothing changed", opened.Secret,
|
|
changed.Generation), nil
|
|
}
|
|
return fmt.Sprintf("the vault now holds %s (generation %d), accepted: sealed at this terminal to the vault's key "+
|
|
"for this call, opened in the vault alone and never remade by it; its machine takes it from the vault",
|
|
opened.Secret, changed.Generation), nil
|
|
}
|
|
|
|
// rotateInVault is `secret rotate` for a module filled the new way: the vault makes the value anew and says it
|
|
// changed; the module's machine takes it from the vault, with no declaration sent.
|
|
//
|
|
// **The vault rotates only with a why** (the review of issue 675): a rotation by hand is an act worth its reason,
|
|
// and the vault refuses one without it, so it is refused here before the vault is asked.
|
|
func rotateInVault(ask vaultAsker, target map[string]any, by, why string) (string, error) {
|
|
if strings.TrimSpace(why) == "" {
|
|
return "", errors.New("not rotated: the vault rotates a secret only with a reason, `--why \"…\"`; nothing was asked")
|
|
}
|
|
args := map[string]any{"by": by, "why": why}
|
|
for k, v := range target {
|
|
args[k] = v
|
|
}
|
|
changed, err := endVaultCall(ask, "rotate", args)
|
|
if err != nil {
|
|
return "", fmt.Errorf("not rotated: %w", err)
|
|
}
|
|
return fmt.Sprintf("rotated %s in the vault (generation %d), asked by %s; the value is sealed and not shown, and "+
|
|
"its machine takes it from the vault", changed.Name, changed.Generation, changed.By), nil
|
|
}
|
|
|
|
// vaultAsked is how the vault's ask at a desk ended: what it kept, or why nothing was given. Never the value.
|
|
type vaultAsked struct {
|
|
Secret string `json:"secret"`
|
|
Generation int `json:"generation"`
|
|
AskedBy string `json:"asked_by"`
|
|
Outcome string `json:"outcome"`
|
|
Unchanged bool `json:"unchanged"`
|
|
}
|
|
|
|
// giveToVault is the desk's path for a module filled the new way (ADR 0349 clause 9, ADR 0277 §22–§27 in the
|
|
// vault; issue 723): one ask of the vault, which records the ask and holds its bounds, asks the desk's prompt itself
|
|
// in its own name, the prompt naming the asker as the vault recorded it, and opens what is typed. The controller
|
|
// never reaches the desk, and nothing typed passes it, sealed or not.
|
|
func (d deskGive) giveToVault(node, module, name, desk string) (string, error) {
|
|
raw, err := d.vault("secret-ask", map[string]any{"node": node, "module": module, "name": name, "desk": desk,
|
|
"asked_by": askedByName(d.askedBy)})
|
|
if err != nil {
|
|
return "", fmt.Errorf("the vault did not ask at the desk on %s: %w", desk, err)
|
|
}
|
|
var asked vaultAsked
|
|
if err := json.Unmarshal(raw, &asked); err != nil || asked.Outcome == "" {
|
|
return "", fmt.Errorf("the vault answered something that is not how its ask at the desk on %s ended", desk)
|
|
}
|
|
switch asked.Outcome {
|
|
case "given":
|
|
case "timed-out":
|
|
return "", fmt.Errorf("%w: the prompt on %s was not answered in time (asked by the vault)", errNothingGiven, desk)
|
|
case "dismissed", "empty":
|
|
return "", fmt.Errorf("%w: the prompt on %s was %s (asked by the vault)", errNothingGiven, desk, asked.Outcome)
|
|
default:
|
|
return "", fmt.Errorf("the vault's ask at the desk on %s ended %s", desk, asked.Outcome)
|
|
}
|
|
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
|
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s; kept in the vault", name,
|
|
module, node, desk, asked.AskedBy),
|
|
Cause: "given-at-the-desk"}
|
|
recorded := ""
|
|
if err := d.record(act); err != nil {
|
|
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
|
|
}
|
|
if d.announce != nil {
|
|
if err := d.announce(node, module, name, "at the desk on "+desk); err != nil {
|
|
recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and kept in the vault as %s (generation %d); "+
|
|
"the vault asked the desk itself and opened it alone, and %s takes it from there", module, node, name, desk,
|
|
asked.Secret, asked.Generation, node) + recorded, nil
|
|
}
|
|
|
|
// vaultIssued is what the vault kept of a value a provider's software issued. Never the value.
|
|
type vaultIssued struct {
|
|
Secret string `json:"secret"`
|
|
Generation int `json:"generation"`
|
|
Unchanged bool `json:"unchanged"`
|
|
// Holders are what the vault kept for each holder when the tool answered a value per holder, and NotAnswered the
|
|
// holders named that it gave nothing for (issue 749).
|
|
Holders []vaultIssuedHolder `json:"holders,omitempty"`
|
|
NotAnswered []issuedHolder `json:"not_answered,omitempty"`
|
|
}
|
|
|
|
// vaultIssuedHolder is what the vault kept for one holder. Never the value.
|
|
type vaultIssuedHolder struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Secret string `json:"secret"`
|
|
Generation int `json:"generation"`
|
|
Unchanged bool `json:"unchanged,omitempty"`
|
|
}
|
|
|
|
// takeIntoVault is `secret issued` for a provider filled the new way (ADR 0349 clause 10; issue 723): one ask of
|
|
// the vault, which asks the provider's tool itself, in its own name, with a key made for the call, and opens what
|
|
// the tool sealed. The controller never reaches the tool, and nothing is sealed, kept or sent here: each holder
|
|
// takes it from the vault.
|
|
func (t issuedTaker) takeIntoVault(provision string, p issuedProvider) (string, error) {
|
|
args := map[string]any{"provision": provision, "provider": p.Node, "module": p.Module, "tool": p.Tool}
|
|
if len(p.Holders) > 0 {
|
|
args["holders"] = p.Holders
|
|
}
|
|
raw, err := t.vault("issued", args)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the vault did not take it from its tool %s: %w", p.Tool, err)
|
|
}
|
|
var changed vaultIssued
|
|
if err := json.Unmarshal(raw, &changed); err != nil || changed.Secret == "" {
|
|
return "", fmt.Errorf("the vault answered something that is not what it kept from its tool %s", p.Tool)
|
|
}
|
|
if len(changed.Holders) > 0 {
|
|
return t.takenPerHolder(provision, p, changed), nil
|
|
}
|
|
if changed.Unchanged {
|
|
return fmt.Sprintf("%s from %s on %s: the vault already holds the value its software issued (generation %d); "+
|
|
"nothing was written", provision, p.Module, p.Node, changed.Generation), nil
|
|
}
|
|
recorded := ""
|
|
if err := t.record(link.HandAct{Verb: handActIssued, Args: []string{provision},
|
|
Why: fmt.Sprintf("%s on %s gave the credential its software issued for %s to the vault, which asked it; kept "+
|
|
"in the vault as %s", p.Module, p.Node, provision, changed.Secret),
|
|
Cause: "issued-by-software"}); err != nil {
|
|
recorded = fmt.Sprintf("\n this was NOT recorded in the hand-act log, and is done anyway: %v", err)
|
|
}
|
|
return fmt.Sprintf("%s from %s on %s: the vault asked its tool itself, and the value its software issued is kept "+
|
|
"in the vault as %s (generation %d); each holder takes it from there%s", provision, p.Module, p.Node,
|
|
changed.Secret, changed.Generation, recorded), nil
|
|
}
|
|
|
|
// takenPerHolder says what the vault kept for each holder when the provider's tool answered a value per holder (issue
|
|
// 749), records the act when any holder's value changed, and names each holder the tool gave nothing for.
|
|
func (t issuedTaker) takenPerHolder(provision string, p issuedProvider, changed vaultIssued) string {
|
|
var kept, moved []string
|
|
for _, h := range changed.Holders {
|
|
holder := issuedHolder{Node: h.Node, Module: h.Module}
|
|
kept = append(kept, fmt.Sprintf("%s as %s (generation %d)", holder, h.Secret, h.Generation))
|
|
if !h.Unchanged {
|
|
moved = append(moved, holder.String())
|
|
}
|
|
}
|
|
missing := ""
|
|
if len(changed.NotAnswered) > 0 {
|
|
names := make([]string, 0, len(changed.NotAnswered))
|
|
for _, h := range changed.NotAnswered {
|
|
names = append(names, h.String())
|
|
}
|
|
missing = fmt.Sprintf("\n its tool gave nothing for %s, which holds no value from it", strings.Join(names, ", "))
|
|
}
|
|
if len(moved) == 0 {
|
|
return fmt.Sprintf("%s from %s on %s: the vault already holds the value its software issued for each holder (%s); "+
|
|
"nothing was written%s", provision, p.Module, p.Node, strings.Join(kept, ", "), missing)
|
|
}
|
|
recorded := ""
|
|
if err := t.record(link.HandAct{Verb: handActIssued, Args: []string{provision},
|
|
Why: fmt.Sprintf("%s on %s gave the vault, which asked it, a credential its software issued for each holder of %s; "+
|
|
"new for %s", p.Module, p.Node, provision, strings.Join(moved, ", ")),
|
|
Cause: "issued-by-software"}); err != nil {
|
|
recorded = fmt.Sprintf("\n this was NOT recorded in the hand-act log, and is done anyway: %v", err)
|
|
}
|
|
return fmt.Sprintf("%s from %s on %s: the vault asked its tool itself, and keeps the value its software issued for "+
|
|
"each holder, handed to that holder alone: %s%s%s", provision, p.Module, p.Node, strings.Join(kept, ", "), missing,
|
|
recorded)
|
|
}
|
|
|
|
// actingAs is who acts in this process, as the vault records it beside the controller that carries the act.
|
|
func actingAs() string {
|
|
return strings.TrimSpace(askedByName(link.Caller()))
|
|
}
|