From 4ead5a3df76cf9deca3e8e8aff6a0418e6d33173 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 13:57:24 +0200 Subject: [PATCH 1/2] Define the node-reads seat, refuse a holder without an account of its own, and tell it where its node keeps a secret (hq ADR 0310, issue 418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a file, searching one, listing a directory, the owner and mode of each component of a path and looking up an account had no mesh tool, so they went through the login shell's execute as the operator's account. node-reads is a node seat with six verbs, each saying the commands it replaces. Its holder must run as an account of its own (ADR 0310 §3), and its own runtime is given MESH_SECRET_PLACES: the directory of every module's own secret, every credential file and grant directory, the vault's directories and the sealed-secret keep, so it refuses them by name before any read (§5). --- internal/catalogue/manifest.go | 1 + internal/catalogue/reads.go | 184 +++++++++++++++++++++++++++++++ internal/catalogue/reads_test.go | 109 ++++++++++++++++++ internal/catalogue/runtime.go | 4 + internal/catalogue/seats.go | 3 + internal/catalogue/seats_test.go | 6 +- 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 internal/catalogue/reads.go create mode 100644 internal/catalogue/reads_test.go diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index efb32aea..47fa72c4 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1689,6 +1689,7 @@ func ParseManifest(raw []byte) (Manifest, error) { // facts about the catalogue and are checked at registration (CatalogueProblems). problems = append(problems, declaredSeatProblems(m)...) problems = append(problems, RunsAsProblems(m)...) + problems = append(problems, ReadsHolderProblems(m)...) if m.Computed != "" && len(m.Resources) > 0 { // One or the other. A module that both ships files and has them computed would leave // nobody able to say where a given file came from. diff --git a/internal/catalogue/reads.go b/internal/catalogue/reads.go new file mode 100644 index 00000000..0f27849c --- /dev/null +++ b/internal/catalogue/reads.go @@ -0,0 +1,184 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" +) + +// A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8). +// +// **A seat of its own on every node.** Reading a file, searching one, listing a directory, the owner and mode +// of each component of a path, and looking up an account or a group are the reads ADR 0268 §5 has narrow tools +// take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every +// node needs them, the control-node included, and another module could offer them instead of the tool +// runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name. +// +// **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259 +// §8), so the node's file modes bound what it sees — never the operator's account, which sees the +// operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept, +// which the controller names for it (secretPlaces), and every answer passes the content rule's secret class +// (mesh-sdk's outward, ADR 0310 §4). + +// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310). +const ReadsSeat = "node-reads" + +// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is +// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5). +const ReadsSecretPlaces = "MESH_SECRET_PLACES" + +// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the +// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in +// every answer it cuts. +func readsVerbs() []Verb { + return []Verb{ + {Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " + + "(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " + + "file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " + + "holds no secret: a component it may not reach says so. At most 200 paths in one call.", + Input: schema(map[string]string{ + "paths": "one absolute path, or several separated by line breaks", + "follow": "\"true\": also say where the path leads once every link is followed", + }, []string{"paths"}, "follow"), + Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}}, + {Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " + + "link is followed, and whether that exists.", + Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}), + Replaces: []string{"readlink", "readlink -f", "realpath"}}, + {Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " + + "link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " + + "is refused by name.", + Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}), + Replaces: []string{"ls", "ls -la"}}, + {Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " + + "(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " + + "kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " + + "number said. /proc and /sys are read as far as the holder's account may.", + Input: schema(map[string]string{ + "path": "an absolute path to a file", + "from": "the first line to answer, counted from 1 (optional)", + "lines": "how many lines to answer (optional)", + "bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + }, []string{"path"}), + Replaces: []string{"cat", "head", "tail", "less"}}, + {Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " + + "every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " + + "8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " + + "binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " + + "secret is withheld. The answer says which bound it met.", + Input: schema(map[string]string{ + "text": "the fixed string to find", + "path": "an absolute path to a file or a directory", + "depth": "how many directories deep, up to 8 (optional)", + "matches": "the most matches to answer, up to 500 (optional)", + "case": "\"false\": match whatever the case (optional)", + }, []string{"text", "path"}, "case"), + Replaces: []string{"grep", "grep -r", "grep -F"}}, + {Name: "account", Description: "One account or group by name or number, as this node's account files say: " + + "an account's number, group, home, shell and every group it is in; a group's number and members. With " + + "neither, every account and group. Never a password field.", + Input: schema(map[string]string{ + "name": "an account's name or number (optional)", + "group": "a group's name or number (optional)", + }, nil), + Replaces: []string{"getent passwd", "getent group", "id", "groups"}}, + } +} + +// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an +// 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. +func ReadsHolderProblems(m Manifest) []string { + if !m.ClaimsSeat(ReadsSeat) || m.RunsAs != "" { + return nil + } + return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ + "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)} +} + +// 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 +// 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 +// place under another dropped, so the list says each refusal once. +func (r Resolution) secretPlaces() []string { + seen := map[string]bool{} + add := func(path string) { + path = strings.TrimSpace(path) + if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") { + return + } + path = filepath.Clean(path) + if path == "/" { + return + } + seen[path] = true + } + for _, m := range r.Modules { + for _, own := range m.OwnSecrets { + add(filepath.Dir(own.Path)) + } + for _, to := range m.SecretRequirements() { + for _, f := range m.SecretFiles(to) { + add(f.Path) + } + } + for _, where := range m.Grants { + add(where) + } + add(m.Keeps) + if providesSecret(m) { + for _, res := range m.Resources { + if fmt.Sprint(res["type"]) == "directory" { + path, _ := res["path"].(string) + add(path) + } + } + } + } + all := make([]string, 0, len(seen)) + for p := range seen { + all = append(all, p) + } + sort.Strings(all) + var out []string + for _, p := range all { + if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") { + continue + } + out = append(out, p) + } + return out +} + +func providesSecret(m Manifest) bool { + for _, p := range m.Provides { + if p.Name == "secret" { + return true + } + } + return false +} + +// 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) { + if !m.ClaimsSeat(ReadsSeat) { + return words, nil + } + body, err := json.Marshal(r.secretPlaces()) + if err != nil { + return nil, err + } + if words == nil { + words = map[string]string{} + } + if _, has := words[ReadsSecretPlaces]; has { + return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)", + m.Module, ReadsSecretPlaces) + } + words[ReadsSecretPlaces] = string(body) + return words, nil +} diff --git a/internal/catalogue/reads_test.go b/internal/catalogue/reads_test.go new file mode 100644 index 00000000..ce94cd0f --- /dev/null +++ b/internal/catalogue/reads_test.go @@ -0,0 +1,109 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// novox/hq ADR 0310 §1, §2: node-reads is a node seat, and its verbs say the commands they replace, so +// mesh_search finds them by `cat`, `grep` and `getent passwd` (ADR 0245 §3). +func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) { + s, ok := SeatNamed(ReadsSeat) + if !ok { + t.Fatalf("the mesh defines no seat %s", ReadsSeat) + } + if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "0310") { + t.Errorf("%s: scope %q, delivers %q, decision %q", ReadsSeat, s.Scope, s.Delivers, s.Decision) + } + replaced := map[string]string{} + for _, v := range s.Serves { + if v.Optional { + t.Errorf("%s.%s is optional: a new seat's holder serves every verb", ReadsSeat, v.Name) + } + if len(v.Replaces) == 0 { + t.Errorf("%s.%s says nothing it replaces", ReadsSeat, v.Name) + } + for _, c := range v.Replaces { + replaced[c] = v.Name + } + } + for command, verb := range map[string]string{"cat": "read", "grep": "search", "getent passwd": "account", + "getent group": "account", "id": "account", "stat": "stat", "readlink": "readlink", "ls": "list"} { + if replaced[command] != verb { + t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], ReadsSeat, verb) + } + } +} + +// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would +// carry — as the operator's account — is refused at parse. +func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", + "claims": [{"name": "node-reads", "scope": "node"}]}`)) + if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { + t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) + } + if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads", + Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 { + t.Errorf("a holder running as its own account was refused: %v", p) + } +} + +// novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory +// of every module's own secret, every credential file and grant directory, the vault's directories and the +// place the operator-sealed secrets are kept — and no other module is told. +func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, + "reads": {"broker": "own"}, "desk": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + reads := aToolsModule(t, "reads", "tools/index.js") + reads.RunsAs, reads.SecretsOwner = "mesh-reads", "mesh-reads" + reads.Claims = []Claim{{Name: ReadsSeat}} + reads.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh-reads/broker"}} + desk := aToolsModule(t, "desk", "tools/index.js") + desk.RunsAs, desk.SecretsOwner = "desk", "desk" + desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}} + vault := Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret"}}, + Keeps: "/var/lib/vault/root", Grants: map[string]string{"secret": "/var/lib/vault/grants"}, + Resources: []map[string]any{ + {"id": "ledger", "type": "directory", "path": "/var/lib/vault/ledger"}, + {"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"}, + OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}} + r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}} + owns, err := r.ownRuntimes(with) + if err != nil { + t.Fatal(err) + } + var given map[string]map[string]string + for _, p := range owns { + if p["id"] == "reads."+OwnRuntimeID() { + if err := json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &given); err != nil { + t.Fatal(err) + } + } + if p["id"] == "desk."+OwnRuntimeID() && strings.Contains(p["env"].(map[string]string)[RuntimeToolEnv], ReadsSecretPlaces) { + t.Error("a module not holding node-reads is told the node's secret places") + } + } + var places []string + if err := json.Unmarshal([]byte(given["reads"][ReadsSecretPlaces]), &places); err != nil { + 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", + "/var/lib/mesh/" + RuntimeModule, "/var/lib/vault/grants", "/var/lib/vault/ledger", "/var/lib/vault/root"} + if strings.Join(places, " ") != strings.Join(want, " ") { + t.Errorf("secret places:\n got %v\nwant %v", places, want) + } +} diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index b7f3fbe4..2f83c219 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -303,6 +303,10 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { if err != nil { return nil, err } + // 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 { + return nil, err + } if len(words) > 0 { body, err := json.Marshal(map[string]map[string]string{m.Module: words}) if err != nil { diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index ec9a9f50..9e07e635 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -310,6 +310,9 @@ var defaultSeats = append([]Seat{ // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). {Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()}, {Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()}, + // A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds + // no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool. + {Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()}, }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 10e144d7..537a6a2f 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -48,7 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // Forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired // into node-uplink (novox/hq ADR 0223); thirty-seven // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); @@ -59,8 +59,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. - if len(Seats()) != 40 { - t.Errorf("the mesh defines %d seats rather than 40; the set is closed, so a change here is "+ + if len(Seats()) != 41 { + t.Errorf("the mesh defines %d seats rather than 41; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } From 63a99d43237f6d2c3aa6d90c585a32047d9fb5b8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 14:30:00 +0200 Subject: [PATCH 2/2] 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) 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. --- internal/catalogue/reads.go | 80 +++++++++++++++++++++++--------- internal/catalogue/reads_test.go | 41 +++++++++++++--- internal/catalogue/runtime.go | 2 +- 3 files changed, 93 insertions(+), 30 deletions(-) diff --git a/internal/catalogue/reads.go b/internal/catalogue/reads.go index 0f27849c..93686a2a 100644 --- a/internal/catalogue/reads.go +++ b/internal/catalogue/reads.go @@ -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 // read as the operator's account, which sees the operator's keys and the agent's credentials. func ReadsHolderProblems(m Manifest) []string { - if !m.ClaimsSeat(ReadsSeat) || m.RunsAs != "" { + if !m.ClaimsSeat(ReadsSeat) { return nil } - return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ - "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)} + if m.RunsAs == "" { + return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ + "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 // §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 -// secrets, and every directory of the module that provides `secret` — the vault. Sorted, each once, and a -// place under another dropped, so the list says each refusal once. -func (r Resolution) secretPlaces() []string { +// secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it, +// a directory declared without a path included. Sorted, each once, and a place under another dropped, so the +// 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{} - add := func(path string) { + var problems []string + add := func(module, what, path string) { path = strings.TrimSpace(path) + if path == "" { + return + } 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 } path = filepath.Clean(path) if path == "/" { + problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what)) return } seen[path] = true } for _, m := range r.Modules { - for _, own := range m.OwnSecrets { - add(filepath.Dir(own.Path)) + for name, own := range m.OwnSecrets { + 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 _, f := range m.SecretFiles(to) { - add(f.Path) + add(m.Module, "credential file for "+to, f.Path) } } - for _, where := range m.Grants { - add(where) + for to, where := range m.Grants { + 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) { - for _, res := range m.Resources { - if fmt.Sprint(res["type"]) == "directory" { - path, _ := res["path"].(string) - add(path) - } + dirs := dirsFor(m, with) + for _, id := range sortedKeys(dirs) { + add(m.Module, "directory "+id, dirs[id]) } } } + 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)) for p := range seen { all = append(all, p) @@ -151,7 +183,7 @@ func (r Resolution) secretPlaces() []string { } out = append(out, p) } - return out + return out, nil } 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. -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) { 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 { return nil, err } diff --git a/internal/catalogue/reads_test.go b/internal/catalogue/reads_test.go index ce94cd0f..90e17daf 100644 --- a/internal/catalogue/reads_test.go +++ b/internal/catalogue/reads_test.go @@ -73,12 +73,11 @@ func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { desk := aToolsModule(t, "desk", "tools/index.js") desk.RunsAs, desk.SecretsOwner = "desk", "desk" desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}} - vault := Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret"}}, - Keeps: "/var/lib/vault/root", Grants: map[string]string{"secret": "/var/lib/vault/grants"}, - Resources: []map[string]any{ - {"id": "ledger", "type": "directory", "path": "/var/lib/vault/ledger"}, - {"id": "grants", "type": "directory", "path": "/var/lib/vault/grants"}, - }} + // The vault as the catalogue declares it: its directories have no path of their own, and the node places them. + vault, err := placedManifest(catalogueManifest(t, "mesh-vault"), with) + if err != nil { + t.Fatal(err) + } 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"}}} 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) } 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, " ") { 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) + } +} diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 2f83c219..874f7108 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -304,7 +304,7 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { return nil, err } // 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 } if len(words) > 0 {