Merge pull request 'Compose each read-write access's grant for the node-engine (hq ADR 0312, issue 537)' (#256) from feat/537-a-read-write-access-grants-the-paths-group into main
This commit was merged in pull request #256.
This commit is contained in:
@@ -246,6 +246,11 @@ type Rendering struct {
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// GrantsGroups says this machine's node-engine reads an access's `grant` (novox/hq ADR 0312: its statement's
|
||||
// contract is link.GrantContract or later). To an older, strict engine the field is not sent, nor the
|
||||
// runtime's restarts on it, and a read-write access is reached as before: by whoever owns the path.
|
||||
GrantsGroups bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -543,6 +548,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
|
||||
var out []map[string]any
|
||||
// The read-write accesses each module grants its tools' account, by module (novox/hq ADR 0312): the runtime
|
||||
// that runs as that account is restarted on them.
|
||||
grantedToTools := map[string][]string{}
|
||||
for _, m := range r.Modules {
|
||||
if with.Adopted && m.Filtering != nil {
|
||||
// Nothing of a module that loads a filter, on an adopted node: its table would drop
|
||||
@@ -1115,6 +1123,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
out = append(out, copied)
|
||||
}
|
||||
|
||||
// **A read-write access grants its path's group to what runs this module's code** (novox/hq ADR 0312):
|
||||
// the containers composed above that mount it, and the account its tools run as. Only to an engine that
|
||||
// reads the field.
|
||||
if with.GrantsGroups {
|
||||
grantedToTools[m.Module] = grantAccesses(out, owner, m.Module, r.toolsAccount(m, with))
|
||||
}
|
||||
|
||||
// **What only the mesh knows, where this module asked for it.** The graph is the control
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
@@ -1141,6 +1156,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
// Restarted when an access moved its account's groups, so its process holds them (novox/hq ADR 0312 §6).
|
||||
for _, m := range r.Modules {
|
||||
if m.RunsAs == "" {
|
||||
restartOnGrants(process, grantedToTools[m.Module])
|
||||
}
|
||||
}
|
||||
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
@@ -1149,6 +1170,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
for _, p := range owns {
|
||||
id := fmt.Sprint(p["id"])
|
||||
owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID())
|
||||
restartOnGrants(p, grantedToTools[owner[id]])
|
||||
out = append(out, p)
|
||||
}
|
||||
// What each module's bundles are given is read as the account the runtime runs as — not what a
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A read-write access grants the path's own group to what runs the module's code (novox/hq ADR 0312, issue 537).
|
||||
//
|
||||
// **The controller says who; the node-engine reads which group.** Which group owns the operator's path is a fact
|
||||
// of the machine the controller cannot know, and the path is never changed (§2): so each read-write access
|
||||
// reaches a machine naming the identities its module's code runs as, and the node-engine reads the path's group
|
||||
// at apply and grants it to them. Two kinds of identity run a module's code here:
|
||||
//
|
||||
// - each of its containers that mounts the path — the source of one of its volumes is the path, or below it —
|
||||
// which gains the group as a supplementary group of its process and is recreated with it;
|
||||
// - the account its tools run as, where it has a tools bundle the machine's runtime loads: the operator's
|
||||
// account the runtime runs as, or the module's own account for a module served by a runtime of its own
|
||||
// (ADR 0259 §8). That runtime names the access under its `restart-on`, so it is restarted when the access
|
||||
// moved the account's groups, and its new process holds the group with no new login (§6).
|
||||
//
|
||||
// A read access grants nothing (§3): it is sent as before. A read-write access whose module runs nothing here is
|
||||
// sent an empty grant, so the node-engine still states it unhealthy while its group cannot write (§2).
|
||||
//
|
||||
// **Only to an engine that reads it.** An older node-engine parses strictly and refuses a whole declaration for a
|
||||
// field it does not know; the field and the restarts it brings are composed only where the engine's statement
|
||||
// says link.GrantContract or later (Rendering.GrantsGroups).
|
||||
|
||||
// GrantField is an access's field naming who its path's group is granted to (novox/hq ADR 0312).
|
||||
const GrantField = "grant"
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one:
|
||||
// no runtime here, no bundle it loads, a module left out of an adopted machine's declaration, or a runtime
|
||||
// running as root on a machine with no account.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// grantAccesses writes the grant of every read-write access of module, among the resources composed so far: the
|
||||
// module's containers that mount the access's path, and account when it is not empty. The ids of the accesses
|
||||
// that grant to account, for the runtime that runs as it to be restarted on.
|
||||
func grantAccesses(out []map[string]any, owner map[string]string, module, account string) []string {
|
||||
var containers []map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == module {
|
||||
containers = append(containers, res)
|
||||
}
|
||||
}
|
||||
var toAccount []string
|
||||
for _, res := range out {
|
||||
if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != module || res["mode"] != AccessReadWrite {
|
||||
continue
|
||||
}
|
||||
path, _ := res["path"].(string)
|
||||
grant := map[string]any{}
|
||||
var mounting []any
|
||||
for _, c := range containers {
|
||||
if mounts(c, path) {
|
||||
mounting = append(mounting, fmt.Sprint(c["id"]))
|
||||
}
|
||||
}
|
||||
if len(mounting) > 0 {
|
||||
grant["containers"] = mounting
|
||||
}
|
||||
if account != "" {
|
||||
grant["account"] = account
|
||||
toAccount = append(toAccount, fmt.Sprint(res["id"]))
|
||||
}
|
||||
res[GrantField] = grant
|
||||
}
|
||||
sort.Strings(toAccount)
|
||||
return toAccount
|
||||
}
|
||||
|
||||
// mounts says whether a container mounts path, or something below it, from the machine. A path that merely
|
||||
// begins with the same letters — /storage/media2 beside /storage/media — is another path. A read-only mount of a
|
||||
// read-write access still counts: the module declared the access read-write, the engine grants the group to what
|
||||
// mounts it, and a read-only mount only means that container chose not to write through it.
|
||||
func mounts(container map[string]any, path string) bool {
|
||||
volumes, _ := container["volumes"].([]any)
|
||||
path = filepath.Clean(path)
|
||||
for _, v := range volumes {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source, _, _ := strings.Cut(s, ":")
|
||||
if !strings.HasPrefix(source, "/") {
|
||||
continue // a named volume: the runtime's, not the operator's path
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
if source == path || strings.HasPrefix(source, path+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// restartOnGrants adds to a runtime process's restart-on the accesses that grant to the account it runs as, so it
|
||||
// is restarted to hold a group the account was put in or taken out of (novox/hq ADR 0312 §6).
|
||||
func restartOnGrants(process map[string]any, ids []string) {
|
||||
if process == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
seen[fmt.Sprint(id)] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
restarts = append(restarts, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
process["restart-on"] = restarts
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0312 and issue 537 on the controller's side: each read-write access reaches the machine
|
||||
// naming who its path's group is granted to — the module's containers that mount it, and the account its tools
|
||||
// run as — and the tool runner is restarted on it; a read access grants nothing; and none of it is sent to a
|
||||
// node-engine older than the field, which would refuse the whole declaration.
|
||||
|
||||
const anImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
// anImporter files into one library it writes and reads another: a container mounting both, a step mounting
|
||||
// neither, and tools served by the machine's runtime.
|
||||
func anImporter(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "importer", "tools/index.js")
|
||||
m.Accesses = []Access{
|
||||
{ID: "music", Path: "/storage/media/music", Mode: AccessReadWrite},
|
||||
{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "importer", "image": anImage,
|
||||
"volumes": []any{"${access:music}/incoming:/music", "${access:movies}:/movies:ro"}},
|
||||
{"id": "sidecar", "type": "container", "name": "importer-sidecar", "image": anImage},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func composeImporter(t *testing.T, r Resolution, with Rendering) map[string]map[string]any {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the node-engine this controller pins would make of it: nothing refused.
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
return byID(out)
|
||||
}
|
||||
|
||||
func TestAReadWriteAccessNamesTheContainersThatMountItAndTheToolsAccount(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}}
|
||||
got := composeImporter(t, r, Rendering{GrantsGroups: true})
|
||||
|
||||
music := got["importer."+AccessID("/storage/media/music")]
|
||||
if music == nil {
|
||||
t.Fatalf("no access resource for the library: %v", keysOf(got))
|
||||
}
|
||||
grant, _ := music[GrantField].(map[string]any)
|
||||
if fmt.Sprint(grant["containers"]) != "[importer.app]" || grant["account"] != "jochen" {
|
||||
t.Errorf("the read-write access grants %v; want the container that mounts it and the tools' account", grant)
|
||||
}
|
||||
// A read access grants nothing (§3).
|
||||
if movies := got["importer."+AccessID("/storage/media/movies")]; movies[GrantField] != nil {
|
||||
t.Errorf("a read access names a grant: %v", movies[GrantField])
|
||||
}
|
||||
// The tool runner is restarted when the access moved its account's groups (§6).
|
||||
runtime := got[RuntimeModule+"."+RuntimeProcessID()]
|
||||
found := false
|
||||
for _, id := range runtime["restart-on"].([]any) {
|
||||
if id == "importer."+AccessID("/storage/media/music") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the tool runner is not restarted on the access that grants its account: %v", runtime["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleOfItsOwnAccountIsGrantedAsThatAccount(t *testing.T) {
|
||||
m := anImporter(t)
|
||||
m.RunsAs = "importer"
|
||||
m.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh/importer/broker"}}
|
||||
runtime := theRuntime(t)
|
||||
runtime.Bundles[0].Binary = "node-tools"
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m, runtime}}
|
||||
with := Rendering{GrantsGroups: true}
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "importer": {"broker": "sealed"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(out)
|
||||
grant, _ := got["importer."+AccessID("/storage/media/music")][GrantField].(map[string]any)
|
||||
if grant["account"] != "importer" {
|
||||
t.Errorf("the access grants %v; want the module's own account", grant)
|
||||
}
|
||||
own := got["importer."+OwnRuntimeID()]
|
||||
if fmt.Sprint(own["restart-on"]) == "" || !restartsOnIt(own["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Errorf("the module's own runtime is not restarted on its access: %v", own["restart-on"])
|
||||
}
|
||||
if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Error("the machine's runtime is restarted on an access of a module it does not serve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatRunsNothingHereSendsAnEmptyGrant(t *testing.T) {
|
||||
m := mod("spool", nil, nil, nil)
|
||||
m.Accesses = []Access{{Path: "/storage/downloads", Mode: AccessReadWrite}}
|
||||
out, err := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, has := byID(out)["spool."+AccessID("/storage/downloads")][GrantField].(map[string]any)
|
||||
if !has || len(grant) != 0 {
|
||||
t.Errorf("a read-write access of a module running nothing here is sent %v (sent %v); want an empty grant, "+
|
||||
"so its group is still judged", grant, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoGrantIsSentToAnEngineOlderThanTheField(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}}
|
||||
got := composeImporter(t, r, Rendering{})
|
||||
for id, res := range got {
|
||||
if _, has := res[GrantField]; has {
|
||||
t.Errorf("%s names a grant to an engine that does not read it", id)
|
||||
}
|
||||
}
|
||||
if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Error("the tool runner is restarted on an access an older engine grants nothing for")
|
||||
}
|
||||
}
|
||||
|
||||
func restartsOnIt(list any, want string) bool {
|
||||
items, _ := list.([]any)
|
||||
for _, i := range items {
|
||||
if fmt.Sprint(i) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func keysOf(m map[string]map[string]any) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Only a container that mounts the access's path, or below it, is named: a path that begins with the same letters is
|
||||
// another path. A read-only mount of a read-write access is still named — the access is read-write, and the grant
|
||||
// follows what mounts it, not how.
|
||||
func TestOnlyAContainerMountingThePathIsNamedReadOnlyIncluded(t *testing.T) {
|
||||
m := mod("library", nil, nil, nil)
|
||||
m.Accesses = []Access{{ID: "media", Path: "/storage/media", Mode: AccessReadWrite}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "beside", "type": "container", "name": "beside", "image": anImage, "volumes": []any{"/storage/media2:/x"}},
|
||||
{"id": "reader", "type": "container", "name": "reader", "image": anImage, "volumes": []any{"${access:media}:/media:ro"}},
|
||||
}
|
||||
out, err := Resolution{Node: "ace", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, _ := byID(out)["library."+AccessID("/storage/media")][GrantField].(map[string]any)
|
||||
if fmt.Sprint(grant["containers"]) != "[library.reader]" {
|
||||
t.Errorf("the access grants %v; want the read-only mount and not /storage/media2", grant)
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,12 @@ const ReadinessContract = 2
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// GrantContract is the statement of an engine that also reads an access's `grant` (novox/hq ADR 0312): it grants a
|
||||
// read-write access's path group to the containers and the account the grant names, takes it back, and states the
|
||||
// access unhealthy while the group cannot write. Only to such an engine is the field sent: an older one parses
|
||||
// strictly and would refuse the whole declaration for it.
|
||||
const GrantContract = 4
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
@@ -487,6 +493,11 @@ const KindAccount = "account"
|
||||
// ReasonUsedAsFound.
|
||||
const KindDirectory = "directory"
|
||||
|
||||
// KindAccess is a read-write access that grants its path's group (novox/hq ADR 0312): healthy while the group may
|
||||
// write the path, unhealthy while it cannot — the operator's to decide, since the mesh changes nothing on the path
|
||||
// — or while the path is owned by a group that is never granted. The node-engine's own (mesh-host internal/link).
|
||||
const KindAccess = "access"
|
||||
|
||||
// ReasonUsedAsFound starts the reason of a directory used as found.
|
||||
const ReasonUsedAsFound = "used as found:"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user