Name the vault's placed directories among a node's secret places, fail loudly on one that cannot be resolved, and keep the reads account out of every group (review 54, issue 418)
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/418-node-reads delivered: every member is delivered

The real mesh-vault manifest declares its directories without a path, so the
list left its state and ledger out, and a place still holding a placeholder
vanished from it without a word. The provider's directories are now resolved
where the node places them, an unresolved place fails the composition naming
the module, and a node-reads holder whose account is put in a group is refused.
This commit is contained in:
jochen
2026-10-12 14:30:00 +02:00
parent 4ead5a3df7
commit 63a99d4323
3 changed files with 93 additions and 30 deletions
+58 -22
View File
@@ -91,54 +91,86 @@ func readsVerbs() []Verb {
// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would // account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would
// read as the operator's account, which sees the operator's keys and the agent's credentials. // read as the operator's account, which sees the operator's keys and the agent's credentials.
func ReadsHolderProblems(m Manifest) []string { func ReadsHolderProblems(m Manifest) []string {
if !m.ClaimsSeat(ReadsSeat) || m.RunsAs != "" { if !m.ClaimsSeat(ReadsSeat) {
return nil return nil
} }
return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ if m.RunsAs == "" {
"bounded by that account's file modes, and the machine's runtime runs as the operator's account "+ return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+
"(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)} "bounded by that account's file modes, and the machine's runtime runs as the operator's account "+
"(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)}
}
// In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes
// are what bound its reads.
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs {
continue
}
if groups, said := r["groups"]; said {
if list, ok := groups.([]any); !ok || len(list) > 0 {
return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+
"reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)}
}
}
}
return nil
} }
// secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310 // secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310
// §5): the directory of every module's own secret, every credential file a module is given and every grant // §5): the directory of every module's own secret, every credential file a module is given and every grant
// directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed // directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed
// secrets, and every directory of the module that provides `secret` — the vault. Sorted, each once, and a // secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it,
// place under another dropped, so the list says each refusal once. // a directory declared without a path included. Sorted, each once, and a place under another dropped, so the
func (r Resolution) secretPlaces() []string { // list says each refusal once.
//
// **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the
// module named: a list that silently shrank would be a refusal the decision promises and nothing does.
func (r Resolution) secretPlaces(with Rendering) ([]string, error) {
seen := map[string]bool{} seen := map[string]bool{}
add := func(path string) { var problems []string
add := func(module, what, path string) {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if path == "" {
return
}
if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") { if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") {
problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node))
return return
} }
path = filepath.Clean(path) path = filepath.Clean(path)
if path == "/" { if path == "/" {
problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what))
return return
} }
seen[path] = true seen[path] = true
} }
for _, m := range r.Modules { for _, m := range r.Modules {
for _, own := range m.OwnSecrets { for name, own := range m.OwnSecrets {
add(filepath.Dir(own.Path)) add(m.Module, "own secret "+name, own.Path)
if strings.HasPrefix(own.Path, "/") {
add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path))
}
} }
for _, to := range m.SecretRequirements() { for _, to := range m.SecretRequirements() {
for _, f := range m.SecretFiles(to) { for _, f := range m.SecretFiles(to) {
add(f.Path) add(m.Module, "credential file for "+to, f.Path)
} }
} }
for _, where := range m.Grants { for to, where := range m.Grants {
add(where) add(m.Module, "grant directory for "+to, where)
} }
add(m.Keeps) add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps)
if providesSecret(m) { if providesSecret(m) {
for _, res := range m.Resources { dirs := dirsFor(m, with)
if fmt.Sprint(res["type"]) == "directory" { for _, id := range sortedKeys(dirs) {
path, _ := res["path"].(string) add(m.Module, "directory "+id, dirs[id])
add(path)
}
} }
} }
} }
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+
"one (novox/hq ADR 0310 §5): %s", ReadsSeat, r.Node, strings.Join(problems, "; "))
}
all := make([]string, 0, len(seen)) all := make([]string, 0, len(seen))
for p := range seen { for p := range seen {
all = append(all, p) all = append(all, p)
@@ -151,7 +183,7 @@ func (r Resolution) secretPlaces() []string {
} }
out = append(out, p) out = append(out, p)
} }
return out return out, nil
} }
func providesSecret(m Manifest) bool { func providesSecret(m Manifest) bool {
@@ -164,11 +196,15 @@ func providesSecret(m Manifest) bool {
} }
// readsWords is what the holder of node-reads is given beside its own words: the node's secret places. // readsWords is what the holder of node-reads is given beside its own words: the node's secret places.
func (r Resolution) readsWords(m Manifest, words map[string]string) (map[string]string, error) { func (r Resolution) readsWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
if !m.ClaimsSeat(ReadsSeat) { if !m.ClaimsSeat(ReadsSeat) {
return words, nil return words, nil
} }
body, err := json.Marshal(r.secretPlaces()) places, err := r.secretPlaces(with)
if err != nil {
return nil, err
}
body, err := json.Marshal(places)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+34 -7
View File
@@ -73,12 +73,11 @@ func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) {
desk := aToolsModule(t, "desk", "tools/index.js") desk := aToolsModule(t, "desk", "tools/index.js")
desk.RunsAs, desk.SecretsOwner = "desk", "desk" desk.RunsAs, desk.SecretsOwner = "desk", "desk"
desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}} desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}}
vault := Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret"}}, // The vault as the catalogue declares it: its directories have no path of their own, and the node places them.
Keeps: "/var/lib/vault/root", Grants: map[string]string{"secret": "/var/lib/vault/grants"}, vault, err := placedManifest(catalogueManifest(t, "mesh-vault"), with)
Resources: []map[string]any{ if err != nil {
{"id": "ledger", "type": "directory", "path": "/var/lib/vault/ledger"}, t.Fatal(err)
{"id": "grants", "type": "directory", "path": "/var/lib/vault/grants"}, }
}}
app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"}, app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"},
OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}} OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}}
r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}} r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}}
@@ -102,8 +101,36 @@ func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) {
t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given) t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given)
} }
want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads", want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads",
"/var/lib/mesh/" + RuntimeModule, "/var/lib/vault/grants", "/var/lib/vault/ledger", "/var/lib/vault/root"} "/var/lib/mesh-vault", "/var/lib/mesh/" + RuntimeModule}
if strings.Join(places, " ") != strings.Join(want, " ") { if strings.Join(places, " ") != strings.Join(want, " ") {
t.Errorf("secret places:\n got %v\nwant %v", places, want) t.Errorf("secret places:\n got %v\nwant %v", places, want)
} }
} }
// The review of #257: a secret place the controller cannot resolve fails the composition, naming the module,
// rather than leaving the list one place shorter.
func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{
{Module: "app", Version: "1", Keeps: "${dir:keep}/sealed"},
}}
if _, err := r.secretPlaces(Rendering{}); err == nil || !strings.Contains(err.Error(), "app") {
t.Errorf("an unresolved place was dropped: %v", err)
}
r.Modules[0].Keeps = "relative/place"
if _, err := r.secretPlaces(Rendering{}); err == nil {
t.Error("a relative place was dropped")
}
}
// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused.
func TestTheReadsAccountIsInNoGroup(t *testing.T) {
m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}},
Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}}
if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") {
t.Errorf("an account in docker was not refused: %v", p)
}
m.Resources[0]["groups"] = []any{}
if p := ReadsHolderProblems(m); len(p) != 0 {
t.Errorf("an account in no group was refused: %v", p)
}
}
+1 -1
View File
@@ -304,7 +304,7 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
return nil, err return nil, err
} }
// The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5). // The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5).
if words, err = r.readsWords(m, words); err != nil { if words, err = r.readsWords(m, words, with); err != nil {
return nil, err return nil, err
} }
if len(words) > 0 { if len(words) > 0 {