Files
mesh-host/internal/apply/grants_test.go
T
jschoubben f4efc3cad0 Grant a read-write access's path group to what runs the module's code, so a module can write shared data without the mesh changing it (hq ADR 0312, issue 537)
The engine reads the group owning the accessed path at apply and gives it to the
containers and the tools' account an access's new `grant` names: --group-add for a
container (recreated with it), an account group for the account (recorded, taken
back as ADR 0252 takes back). The path is never changed; an access whose group
cannot write is stated unhealthy for the operator to decide; group 0 and the
root-granting groups are refused by name. The tool runner restarts through the
restart-on the controller gives it, because the access reports updated exactly
when the account's groups moved. Health says GrantContract (4), which tells the
controller it may send `grant`.
2026-10-12 12:23:38 +02:00

458 lines
18 KiB
Go

package apply
import (
"context"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0312 and issue 537: a read-write access grants its path's own group to the containers and
// the account the controller names, and the path is never changed; a read access grants nothing; the root group,
// and a group whose members act as root, are refused by name; what was granted is taken back as ADR 0252 takes
// back; a container is recreated with the group, and a unit of the machine's own manager running as the account
// is restarted once the account holds it.
// grantMachine is the group database of groupDB, a container runtime that keeps each container's spec label and
// the arguments it was run with, and a service manager that notes, at each restart, the groups the account holds
// in the database then — which is what the machine's own manager gives the unit's new process.
type grantMachine struct {
*groupDB
containers map[string]string
runs map[string][]string
restarts []string
}
func newGrantMachine(account string, in []string, exists ...string) *grantMachine {
g := newGroupDB(in, exists...)
g.account = account
return &grantMachine{groupDB: g, containers: map[string]string{}, runs: map[string][]string{}}
}
func (m *grantMachine) run(ctx context.Context, name string, args ...string) (string, error) {
a := oldShape(name, args)
switch name {
case "docker":
m.asked = append(m.asked, name+" "+strings.Join(a, " "))
switch a[0] {
case "info":
return "27.0\n", nil
case "container":
spec, there := m.containers[a[len(a)-1]]
if !there {
return "", errors.New("no such container")
}
return "true\t" + spec, nil
case "rm":
delete(m.containers, a[len(a)-1])
case "run":
var named, spec string
for i := 0; i+1 < len(a); i++ {
switch {
case a[i] == "--name":
named = a[i+1]
case a[i] == "--label" && strings.HasPrefix(a[i+1], specLabel+"="):
spec = strings.TrimPrefix(a[i+1], specLabel+"=")
}
}
m.containers[named] = spec
m.runs[named] = a
return "deadbeef\n", nil
}
return "", nil
case "systemctl":
m.asked = append(m.asked, name+" "+strings.Join(a, " "))
if a[0] == "restart" {
m.restarts = append(m.restarts, a[1]+": "+m.groups())
}
return "", nil
}
return m.groupDB.run(ctx, name, args...)
}
// groupOfPaths stands in for the machine's group database and the paths' groups for the length of a test.
func groupOfPaths(t *testing.T, groups map[string]PathGroup) {
t.Helper()
was := GroupOfPath
GroupOfPath = func(path string) (PathGroup, error) {
if _, err := os.Stat(path); err != nil {
return PathGroup{}, err
}
g, ok := groups[path]
if !ok {
return PathGroup{}, errors.New("no group in this test for " + path)
}
return g, nil
}
t.Cleanup(func() { GroupOfPath = was })
}
func accessGranting(id, path, account string, containers ...string) string {
grant := `"grant":{"account":"` + account + `"`
if len(containers) > 0 {
grant += `,"containers":["` + strings.Join(containers, `","`) + `"]`
}
return `{"id":"` + id + `","type":"access","path":"` + path + `","mode":"read-write",` + grant + `}}`
}
func mounting(id, path string) string {
return `{"id":"` + id + `","type":"container","name":"` + strings.ReplaceAll(id, ".", "-") + `","image":"` + pinned +
`","volumes":["` + path + `:/library"]}`
}
func applyGrants(t *testing.T, m *grantMachine, known store.State, resources ...string) (Report, store.State, error) {
t.Helper()
return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+
strings.Join(resources, ",")+`]}`), known, store.OriginDeclared, m.run, nil, nil)
}
func statOf(t *testing.T, path string) string {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
uid, gid, _ := OwnerOf(info)
return fmt.Sprintf("%s %d:%d %s", info.Mode(), uid, gid, info.ModTime())
}
func TestAReadWriteAccessGrantsItsPathsGroupToTheContainerAndTheAccountAndLeavesThePath(t *testing.T) {
library := t.TempDir()
if err := os.WriteFile(filepath.Join(library, "album"), []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
before, beforeInside := statOf(t, library), statOf(t, filepath.Join(library, "album"))
m := newGrantMachine("operator", []string{"operator"}, "media")
report, state, err := applyGrants(t, m, store.State{},
accessGranting("importer.music", library, "operator", "importer.app"), mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(" "+m.groups()+" ", " media ") {
t.Fatalf("the account was not put in the path's group: %q", m.groups())
}
if run := strings.Join(m.runs["importer-app"], " "); !strings.Contains(run, "--group-add 1001") {
t.Fatalf("the container was not created with the path's group: %s", run)
}
o := outcomeOf(report, "importer.music")
if o.Action != "updated" || !strings.Contains(o.Detail, "grants media (gid 1001)") ||
!strings.Contains(o.Detail, "put operator in media") {
t.Errorf("the access did not say what it granted: %+v", o)
}
if rec, _ := state.Find("importer.music"); rec.User != "operator" || strings.Join(rec.Groups, " ") != "media" {
t.Errorf("the access did not record the group it put the account in: %+v", rec)
}
// The path, and what is under it, exactly as they were (ADR 0312 §2).
if after := statOf(t, library); after != before {
t.Errorf("the accessed path changed: %s, was %s", after, before)
}
if after := statOf(t, filepath.Join(library, "album")); after != beforeInside {
t.Errorf("a file under the accessed path changed: %s, was %s", after, beforeInside)
}
for _, asked := range m.asked {
for _, verb := range []string{"chown", "chmod", "chgrp", "setfacl"} {
if strings.HasPrefix(asked, verb) || strings.Contains(asked, " "+verb+" ") {
t.Errorf("the engine asked the machine to change something: %q", asked)
}
}
}
// Applied again: nothing moves, and nothing is recreated.
m.runs = map[string][]string{}
report, _, err = applyGrants(t, m, state,
accessGranting("importer.music", library, "operator", "importer.app"), mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "importer.music"); o.Action != "unchanged" {
t.Errorf("a second apply moved the access: %+v", o)
}
if len(m.runs) != 0 {
t.Errorf("a second apply recreated the container: %v", m.runs)
}
}
func TestAReadAccessGrantsNothing(t *testing.T) {
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
// A grant on a read access is refused whole: the controller asked for something the decision forbids.
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"player.music","type":"access","path":"` +
library + `","mode":"read","grant":{"account":"operator"}}]}`))
if err == nil || !strings.Contains(err.Error(), "a read access grants nothing") {
t.Fatalf("a grant on a read access was not refused by name: %v", err)
}
m := newGrantMachine("operator", []string{"operator"}, "media")
report, state, err := applyGrants(t, m, store.State{},
`{"id":"player.music","type":"access","path":"`+library+`","mode":"read"}`, mounting("player.app", library))
if err != nil {
t.Fatal(err)
}
if strings.Contains(m.groups(), "media") {
t.Errorf("a read access put the account in a group: %q", m.groups())
}
if run := strings.Join(m.runs["player-app"], " "); strings.Contains(run, "--group-add") {
t.Errorf("a read access gave its container a group: %s", run)
}
if o := outcomeOf(report, "player.music"); o.Action != "unchanged" {
t.Errorf("a read access moved something: %+v", o)
}
if rec, _ := state.Find("player.music"); len(rec.Groups) != 0 || rec.User != "" {
t.Errorf("a read access recorded a grant: %+v", rec)
}
}
func TestAnAccessWhoseGroupCannotWriteIsGrantedAndSaysTheOperatorDecides(t *testing.T) {
spool := t.TempDir()
groupOfPaths(t, map[string]PathGroup{spool: {GID: 1002, Name: "downloads", Writable: false}})
before := statOf(t, spool)
m := newGrantMachine("operator", nil, "downloads")
report, _, err := applyGrants(t, m, store.State{},
accessGranting("nzbget.downloads", spool, "operator", "nzbget.app"), mounting("nzbget.app", spool))
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "nzbget.downloads")
if !strings.Contains(o.Detail, "its group cannot write it") || !strings.Contains(o.Detail, "the operator decides") ||
!strings.Contains(o.Detail, "nothing was changed") {
t.Errorf("the access did not say its group cannot write and that the operator decides: %+v", o)
}
if after := statOf(t, spool); after != before {
t.Errorf("the engine changed a path whose group cannot write: %s, was %s", after, before)
}
// The words the look states it unhealthy with: the remedy is the operator's, and the path is never in them.
words := CannotWriteWords(PathGroup{GID: 1002, Name: "downloads"})
if !strings.Contains(words, "the operator's to decide") || strings.Contains(words, spool) {
t.Errorf("the unhealthy reason is %q", words)
}
}
func TestTheRootGroupIsNeverGranted(t *testing.T) {
for _, g := range []PathGroup{{GID: 0, Name: "root", Writable: true}, {GID: 968, Name: "docker", Writable: true},
{GID: 10, Name: "wheel", Writable: true}} {
t.Run(g.Name, func(t *testing.T) {
shared := t.TempDir()
groupOfPaths(t, map[string]PathGroup{shared: g})
m := newGrantMachine("operator", nil, g.Name)
report, _, err := applyGrants(t, m, store.State{},
accessGranting("importer.shared", shared, "operator", "importer.app"), mounting("importer.app", shared))
if err == nil {
t.Fatal("a read-write access to a path of a root-granting group applied")
}
var failed *Error
if !errors.As(err, &failed) || failed.Resource != "importer.shared" ||
!strings.Contains(err.Error(), g.Name) || !strings.Contains(err.Error(), "never") {
t.Errorf("the refusal does not name the access and the group: %v", err)
}
if strings.Contains(m.groups(), g.Name) {
t.Errorf("the account was put in %s", g.Name)
}
if run := strings.Join(m.runs["importer-app"], " "); strings.Contains(run, "--group-add") {
t.Errorf("the container was given a root-granting group: %s", run)
}
_ = report
})
}
}
func TestAGrantedGroupIsTakenBackUnlessStillAskedForOrHeldBefore(t *testing.T) {
music, movies := t.TempDir(), t.TempDir()
groupOfPaths(t, map[string]PathGroup{music: {GID: 1001, Name: "media", Writable: true},
movies: {GID: 1001, Name: "media", Writable: true}})
t.Run("no longer asked", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"))
if err != nil {
t.Fatal(err)
}
report, state, err := applyGrants(t, m, state, mounting("other.app", t.TempDir()))
if err != nil {
t.Fatal(err)
}
if strings.Contains(m.groups(), "media") {
t.Errorf("the group the mesh granted was not taken back: %q", m.groups())
}
if o := outcomeOf(report, "importer.music"); o.Action != "restored" || !strings.Contains(o.Detail, "taken out of media") {
t.Errorf("the removal did not say it took the group back: %+v", o)
}
if _, still := state.Find("importer.music"); still {
t.Error("the access is still recorded")
}
})
t.Run("still asked by another access", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"),
accessGranting("sonarr.movies", movies, "operator"))
if err != nil {
t.Fatal(err)
}
report, _, err := applyGrants(t, m, state, accessGranting("sonarr.movies", movies, "operator"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Errorf("a group another access still grants was taken back: %q", m.groups())
}
if o := outcomeOf(report, "importer.music"); !strings.Contains(o.Detail, "still asks for") {
t.Errorf("the removal did not say why the group stayed: %+v", o)
}
})
t.Run("still asked by a user resource", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator"}, "media")
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"))
if err != nil {
t.Fatal(err)
}
if _, _, err := applyGrants(t, m, state,
`{"id":"player.account","type":"user","name":"operator","groups":["media"]}`); err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Errorf("a group a user resource asks for was taken back: %q", m.groups())
}
})
t.Run("held before", func(t *testing.T) {
m := newGrantMachine("operator", []string{"operator", "media"})
_, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"))
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("importer.music"); len(rec.Groups) != 0 {
t.Fatalf("a group the account held before was recorded as the mesh's: %+v", rec)
}
if _, _, err := applyGrants(t, m, state, mounting("other.app", t.TempDir())); err != nil {
t.Fatal(err)
}
if !strings.Contains(m.groups(), "media") {
t.Errorf("a group the account held before the mesh was taken back: %q", m.groups())
}
})
}
func TestAContainerIsRecreatedWithTheGroupItIsGranted(t *testing.T) {
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
m := newGrantMachine("operator", nil, "media")
// Running before its access granted anything: a controller older than the grant.
_, state, err := applyGrants(t, m, store.State{},
`{"id":"importer.music","type":"access","path":"`+library+`","mode":"read-write"}`, mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
if run := strings.Join(m.runs["importer-app"], " "); strings.Contains(run, "--group-add") {
t.Fatalf("a container granted nothing was given a group: %s", run)
}
report, _, err := applyGrants(t, m, state,
accessGranting("importer.music", library, "", "importer.app"), mounting("importer.app", library))
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "importer.app")
if o.Action != "updated" || !strings.Contains(o.Detail, "recreated to hold the groups") {
t.Errorf("the container was not recreated for its group: %+v", o)
}
if run := strings.Join(m.runs["importer-app"], " "); !strings.Contains(run, "--group-add 1001") {
t.Errorf("the recreated container does not hold the group: %s", run)
}
}
func TestTheToolRunnerIsRestartedOnceItsAccountHoldsTheGroup(t *testing.T) {
me, err := user.Current()
if err != nil {
t.Skip("no current account to run the tool runner as")
}
units, bundles := t.TempDir(), t.TempDir()
wasUnits, wasBundles := unitDir, daemonRoot
unitDir, daemonRoot = units, bundles
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
m := newGrantMachine(me.Username, nil, "media")
body, digest := anArchive(t, map[string]string{"main.js": "console.log(1)\n"})
runner := `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"` + serving(t, body) +
`","digest":"` + digest + `","run":["node","main.js"],"user":"` + me.Username +
`","restart-on":["importer.music"]}`
// Running before the grant.
_, state, err := applyGrants(t, m, store.State{}, runner)
if err != nil {
t.Fatal(err)
}
m.restarts = nil
report, state, err := applyGrants(t, m, state, accessGranting("importer.music", library, me.Username), runner)
if err != nil {
t.Fatal(err)
}
if len(m.restarts) != 1 || !strings.HasPrefix(m.restarts[0], "node-tools.service: ") ||
!strings.Contains(m.restarts[0], "media") {
t.Fatalf("the tool runner was not restarted once its account held the group: %v", m.restarts)
}
if o := outcomeOf(report, "node-tools.runtime"); !strings.Contains(o.Detail, "importer.music") {
t.Errorf("the restart does not say what it was for: %+v", o)
}
// And not again while nothing moves.
m.restarts = nil
if _, _, err := applyGrants(t, m, state, accessGranting("importer.music", library, me.Username), runner); err != nil {
t.Fatal(err)
}
if len(m.restarts) != 0 {
t.Errorf("the tool runner was restarted with nothing moved: %v", m.restarts)
}
}
// sessionReader is an account whose own service manager runs, holding the groups it began with.
type sessionReader struct{ database, session []string }
func (s sessionReader) InDatabase(context.Context, string) ([]string, error) { return s.database, nil }
func (s sessionReader) Session(_ context.Context, _ string, groups []string) (accounts.Session, error) {
has := map[string]bool{}
for _, g := range s.session {
has[g] = true
}
return accounts.Session{Running: true, Has: has}, nil
}
func (s sessionReader) Escalation(context.Context, string, []string) ([]string, error) {
return nil, nil
}
func TestAnAccountsOwnSessionIsSaidReloginNeededForAGrantedGroup(t *testing.T) {
library := t.TempDir()
groupOfPaths(t, map[string]PathGroup{library: {GID: 1001, Name: "media", Writable: true}})
d := parse(t, `{"declaration":1,"resources":[`+accessGranting("importer.music", library, "operator")+`]}`)
as := GrantedAccounts(d, nil)
if len(as) != 1 || as[0].Module != "importer" || as[0].Name != "operator" || strings.Join(as[0].Groups, " ") != "media" {
t.Fatalf("the account the access grants is not judged: %+v", as)
}
j := accounts.New(sessionReader{database: []string{"operator", "media"}, session: []string{"operator"}})
j.Set(as)
st, _ := j.Look(context.Background())
if len(st.Accounts) != 1 || !strings.HasPrefix(st.Accounts[0].Reason, accounts.ReasonRelogin) {
t.Errorf("a session of the account's own manager that began before the grant is not said relogin needed: %+v",
st.Accounts)
}
// A read access, or one refused, asks nothing of the judge.
groupOfPaths(t, map[string]PathGroup{library: {GID: 0, Name: "root", Writable: true}})
if as := GrantedAccounts(d, nil); len(as) != 0 {
t.Errorf("a refused grant is judged: %+v", as)
}
}