ADR 0570 clauses 1, 2 and 6 move the plan to mesh-provisioner_plans, fold needs into the seat and give the node-engine the secret's fill, so the client no longer holds a value (issue 1954, V3).
335 lines
13 KiB
Go
335 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"github.com/nats-io/nats.go"
|
|
)
|
|
|
|
// A node's secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647), against a
|
|
// real bus whose accounts are the client's as mesh-controller composes them: a moved module's file is filled with
|
|
// its secret from the vault's get, asked in the client's own name and answered sealed to its key; the value never
|
|
// crosses the bus in the clear; a secret rotated in the vault reaches its file without a new declaration; a secret
|
|
// the vault does not hold yet is waited for, and a refusal fails the module with the vault's reason.
|
|
//
|
|
// The vault is a stand-in answering `mesh-vault.get` as its seat does on the bus: on the subject naming the caller,
|
|
// sealing to the key the caller sends, handing only what it allows that caller (the vault's own tests prove its
|
|
// rule), and saying `secret-changed` with no value.
|
|
|
|
// standInVault answers get on the bus, for provisioner.g14 alone and only the names it allows.
|
|
type standInVault struct {
|
|
t *testing.T
|
|
conn *nats.Conn
|
|
mu sync.Mutex
|
|
held map[string]string
|
|
gen map[string]int
|
|
allowed map[string]bool
|
|
asked map[string]int
|
|
refuse string
|
|
}
|
|
|
|
func startVault(t *testing.T, b *aBus) *standInVault {
|
|
t.Helper()
|
|
v := &standInVault{t: t, conn: b.conn, held: map[string]string{}, gen: map[string]int{}, allowed: map[string]bool{},
|
|
asked: map[string]int{}}
|
|
sub, err := b.conn.Subscribe("mesh.seat.mesh-vault.call.get.*", func(m *nats.Msg) {
|
|
caller := strings.ReplaceAll(m.Subject[strings.LastIndex(m.Subject, ".")+1:], "~", ".")
|
|
var args struct {
|
|
Name string `json:"name"`
|
|
PublicKey string `json:"public_key"`
|
|
}
|
|
_ = json.Unmarshal(m.Data, &args)
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
v.asked[args.Name]++
|
|
answer := map[string]any{}
|
|
value, held := v.held[args.Name]
|
|
switch {
|
|
case caller != "provisioner."+node || !v.allowed[args.Name]:
|
|
answer["error"] = caller + " may not ask for " + args.Name + ": not on the list"
|
|
case v.refuse != "":
|
|
answer["error"] = v.refuse
|
|
case !held:
|
|
answer["error"] = "the vault holds no secret named " + args.Name
|
|
default:
|
|
box, err := seal.Seal(value, args.PublicKey)
|
|
if err != nil {
|
|
answer["error"] = err.Error()
|
|
break
|
|
}
|
|
answer["result"] = map[string]any{"name": args.Name, "generation": v.gen[args.Name],
|
|
"changed-at": time.Now().UTC(), "sealed": box}
|
|
}
|
|
body, _ := json.Marshal(answer)
|
|
_ = m.Respond(body)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
_ = b.conn.Flush()
|
|
return v
|
|
}
|
|
|
|
// keep holds value as name and allows the client to ask for it; with say, it says secret-changed as the vault does.
|
|
func (v *standInVault) keep(name, value string, say bool) {
|
|
v.mu.Lock()
|
|
v.held[name] = value
|
|
v.gen[name]++
|
|
v.allowed[name] = true
|
|
gen := v.gen[name]
|
|
v.mu.Unlock()
|
|
if say {
|
|
body, _ := json.Marshal(map[string]any{"name": name, "by": "the test", "at": time.Now().UTC(), "generation": gen})
|
|
if err := v.conn.Publish(SecretChangedSubject, body); err != nil {
|
|
v.t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (v *standInVault) times(name string) int {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
return v.asked[name]
|
|
}
|
|
|
|
// sniffer hears every message on the bus, to prove a value never crosses it in the clear.
|
|
type sniffer struct {
|
|
mu sync.Mutex
|
|
seen []string
|
|
}
|
|
|
|
func sniff(t *testing.T, b *aBus) *sniffer {
|
|
t.Helper()
|
|
s := &sniffer{}
|
|
sub, err := b.conn.Subscribe(">", func(m *nats.Msg) {
|
|
s.mu.Lock()
|
|
s.seen = append(s.seen, m.Subject+" "+string(m.Data))
|
|
s.mu.Unlock()
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
_ = b.conn.Flush()
|
|
return s
|
|
}
|
|
|
|
func (s *sniffer) carrying(value string) []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []string
|
|
for _, m := range s.seen {
|
|
if strings.Contains(m, value) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *sniffer) heard() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.seen)
|
|
}
|
|
|
|
const appConf, appDB = "/etc/app/app.conf", "/var/lib/app/db-password"
|
|
|
|
// app is a moved module with a template naming a secret and a config value, and a secret kept in a file of its own.
|
|
func app(t *testing.T) Plan {
|
|
tpl, _ := templates(t, map[string]string{"app.conf": "token = ${secret:app-token}\nfont = ${config:font-size}\n"})
|
|
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf",
|
|
OwnerAtRuntime: "owned", Takes: "restart-on"}},
|
|
Secrets: []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}}
|
|
}
|
|
|
|
// ADR 0570 clause 6: a template's `${secret:}` placeholder, a secret's own file and a required credential's file are
|
|
// handed to the node-engine with the placeholder left in, for it to fill at the write; the client never asks the vault
|
|
// for them, so no value reaches it. The template's other values are filled.
|
|
func TestNatsAModulesSecretsAreLeftForTheNodeEngineAndTheVaultIsNotAsked(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "tok", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
vault.keep(forgeHolder, "forge", false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned", forgeFile: "owned", settingsFile: "owned"})
|
|
b.config(t, "app", "font-size", "13")
|
|
b.assign(t, "app", app(t))
|
|
b.assign(t, "claude-code", claudeCode(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "app.conf is filled but for its secret", func() bool {
|
|
return e.read(t, appConf) == "token = ${secret:app-token}\nfont = 13\n"
|
|
})
|
|
eventually(t, "the secret's own file holds its placeholder alone", func() bool {
|
|
return e.read(t, appDB) == "${secret:db-password}"
|
|
})
|
|
eventually(t, "the credential's file holds its placeholder alone", func() bool {
|
|
return e.read(t, forgeFile) == "${secret:gitea-read}"
|
|
})
|
|
eventually(t, "the template naming the credential keeps its placeholder", func() bool {
|
|
return e.read(t, settingsFile) == `{"forge":"${secret:gitea-read}"}`
|
|
})
|
|
eventually(t, "prov.applied says applied", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusApplied && len(r.Files) == 2
|
|
})
|
|
if n := vault.times("own/g14/app/app-token") + vault.times("own/g14/app/db-password") + vault.times(forgeHolder); n != 0 {
|
|
t.Errorf("the vault was asked %d times for a module's own secrets, want none", n)
|
|
}
|
|
}
|
|
|
|
// The plan's secrets are read strictly: a name that is not a secret's, one named twice, and a file path that is not
|
|
// clean and absolute, or a template's, each refuse the plan.
|
|
func TestAPlansSecretsAreReadStrictly(t *testing.T) {
|
|
base := func() Plan {
|
|
return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
|
|
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}}}
|
|
}
|
|
for why, secrets := range map[string][]PlannedSecret{
|
|
"a name that is not a secret's": {{Name: "App Token"}},
|
|
"a name given twice": {{Name: "t"}, {Name: "t"}},
|
|
"a relative path": {{Name: "t", Path: "etc/t"}},
|
|
"a template's path": {{Name: "t", Path: appConf}},
|
|
} {
|
|
p := base()
|
|
p.Secrets = secrets
|
|
if len(p.problems()) == 0 {
|
|
t.Errorf("a plan with %s was taken", why)
|
|
}
|
|
}
|
|
p := base()
|
|
p.Secrets = []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}
|
|
if wrong := p.problems(); len(wrong) > 0 {
|
|
t.Errorf("a good plan was refused: %v", wrong)
|
|
}
|
|
}
|
|
|
|
// The forge's read credential for claude-code on this machine (issue 757, issue 737): git's credential-store line
|
|
// the forge's software issued for this holder alone, kept by the vault as
|
|
// provision/<provider>/gitea-read/<node>/claude-code.
|
|
const (
|
|
forgeFile = "/var/lib/claude-code/state/gitea-read.secret"
|
|
settingsFile = "/etc/claude-code/managed-settings.json"
|
|
forgeHolder = "provision/novox/gitea-read/" + node + "/claude-code"
|
|
)
|
|
|
|
// claudeCode is claude-code's plan with the credential of what it requires, gitea-read from novox, kept in a file of
|
|
// its own and named in a template.
|
|
func claudeCode(t *testing.T) Plan {
|
|
tpl, _ := templates(t, map[string]string{"managed-settings.json": `{"forge":"${secret:gitea-read}"}`})
|
|
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "settings", Path: settingsFile,
|
|
Template: "managed-settings.json", OwnerAtRuntime: "owned"}},
|
|
Provisions: []PlannedProvision{{Name: "gitea-read", Provision: "gitea-read", Provider: "novox",
|
|
Path: forgeFile}}}
|
|
}
|
|
|
|
// The plan's provisions are read strictly.
|
|
func TestAPlansProvisionsAreReadStrictly(t *testing.T) {
|
|
base := func() Plan {
|
|
return Plan{Templates: Templates{Source: "x", Digest: "sha256:" + strings.Repeat("0", 64)},
|
|
Files: []PlannedFile{{ID: "conf", Path: appConf, Template: "app.conf", OwnerAtRuntime: "owned"}},
|
|
Secrets: []PlannedSecret{{Name: "own"}}}
|
|
}
|
|
good := PlannedProvision{Name: "gitea-read", Provision: "gitea-read", Provider: "novox", Path: forgeFile}
|
|
for why, change := range map[string]func(*PlannedProvision){
|
|
"a name that is not a secret's": func(p *PlannedProvision) { p.Name = "Gitea Read" },
|
|
"a name an own secret has": func(p *PlannedProvision) { p.Name = "own" },
|
|
"no provision": func(p *PlannedProvision) { p.Provision = "" },
|
|
"a provider that is no machine": func(p *PlannedProvision) { p.Provider = "../ace" },
|
|
"no provider": func(p *PlannedProvision) { p.Provider = "" },
|
|
"a relative path": func(p *PlannedProvision) { p.Path = "var/x" },
|
|
"a template's path": func(p *PlannedProvision) { p.Path = appConf },
|
|
} {
|
|
p := base()
|
|
pr := good
|
|
change(&pr)
|
|
p.Provisions = []PlannedProvision{pr}
|
|
if len(p.problems()) == 0 {
|
|
t.Errorf("a plan with %s was taken", why)
|
|
}
|
|
}
|
|
p := base()
|
|
p.Provisions = []PlannedProvision{good}
|
|
if wrong := p.problems(); len(wrong) > 0 {
|
|
t.Errorf("a good plan was refused: %v", wrong)
|
|
}
|
|
p.Provisions = append(p.Provisions, good)
|
|
if len(p.problems()) == 0 {
|
|
t.Error("a plan naming one credential twice was taken")
|
|
}
|
|
}
|
|
|
|
const (
|
|
dbFile = "/var/lib/nextcloud/state/database.secret"
|
|
dbConf = "/etc/nextcloud/config.php"
|
|
dbHolder = "pair/novox/postgres-database/" + node + "/nextcloud"
|
|
)
|
|
|
|
func nextcloud(t *testing.T) Plan {
|
|
tpl, _ := templates(t, map[string]string{"config.php": `dbpass=${secret:database}`})
|
|
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "config", Path: dbConf, Template: "config.php",
|
|
OwnerAtRuntime: "owned"}},
|
|
Provisions: []PlannedProvision{{Name: "database", Provision: "postgres-database", Provider: "novox",
|
|
ProviderModule: "postgres", CredentialForm: "pair", Path: dbFile}}}
|
|
}
|
|
|
|
func TestAConsumersLoginIsNamedWithItsLocalName(t *testing.T) {
|
|
if got := PairName("novox", "postgres-database", "g14", "umami", "stats"); got != "pair/novox/postgres-database/g14/umami/stats" {
|
|
t.Errorf("a login under a local name: %s", got)
|
|
}
|
|
}
|
|
|
|
// 1553/AC1: a file holding one known secret is filled although a template of the module waits for a bound value;
|
|
// the template's file is not filled, and the report says waiting, names the value, and lists the file it filled.
|
|
func TestNatsAKnownSecretsFileIsFilledWhileATemplateWaits1553(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "tok", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
// No config font-size: the template waits for it.
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "the secret's file is filled", func() bool { return e.read(t, appDB) == "${secret:db-password}" })
|
|
eventually(t, "prov.applied says waiting for the value, with the file it filled", func() bool {
|
|
r, _ := heard.last("app")
|
|
return r.Status == StatusWaiting && len(r.WaitsFor) == 1 && strings.HasPrefix(r.WaitsFor[0], "config:font-size") &&
|
|
len(r.Files) == 1 && r.Files[0].Path == appDB
|
|
})
|
|
if _, asked := e.counts(appConf); asked != 0 {
|
|
t.Error("the template waiting for a value was filled")
|
|
}
|
|
}
|
|
|
|
// 1553/AC3: a template one of whose values waits keeps its file as it is: Fill is never called for it, so no
|
|
// half-filled text is written, though its other value (the secret) is known.
|
|
func TestNatsATemplateWithAWaitingValueKeepsItsFileAsItIs1553(t *testing.T) {
|
|
b := startBus(t)
|
|
heard := b.listen(t)
|
|
vault := startVault(t, b)
|
|
vault.keep("own/g14/app/app-token", "tok", false)
|
|
vault.keep("own/g14/app/db-password", "pw", false)
|
|
e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"})
|
|
b.assign(t, "app", app(t))
|
|
aClient(t, b, e, t.TempDir())
|
|
|
|
eventually(t, "it waits", func() bool { r, _ := heard.last("app"); return r.Status == StatusWaiting })
|
|
eventually(t, "the other file is filled, so the pass is over", func() bool { return e.read(t, appDB) == "${secret:db-password}" })
|
|
time.Sleep(200 * time.Millisecond)
|
|
if _, asked := e.counts(appConf); asked != 0 {
|
|
t.Errorf("Fill was called %d times for a template with a waiting value", asked)
|
|
}
|
|
if got := e.read(t, appConf); got != "" {
|
|
t.Errorf("the template's file holds %q, want it untouched", got)
|
|
}
|
|
}
|