diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 35295a23..be5d13dd 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1718,6 +1718,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..93686a2a --- /dev/null +++ b/internal/catalogue/reads.go @@ -0,0 +1,220 @@ +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) { + return nil + } + 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 — 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{} + 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 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(m.Module, "credential file for "+to, f.Path) + } + } + for to, where := range m.Grants { + add(m.Module, "grant directory for "+to, where) + } + add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps) + if providesSecret(m) { + 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) + } + 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, nil +} + +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, with Rendering) (map[string]string, error) { + if !m.ClaimsSeat(ReadsSeat) { + return words, nil + } + places, err := r.secretPlaces(with) + if err != nil { + return nil, err + } + body, err := json.Marshal(places) + 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..90e17daf --- /dev/null +++ b/internal/catalogue/reads_test.go @@ -0,0 +1,136 @@ +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"}} + // 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}} + 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-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 b7f3fbe4..874f7108 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, with); 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()) } }