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.
221 lines
10 KiB
Go
221 lines
10 KiB
Go
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
|
|
}
|