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`.
458 lines
18 KiB
Go
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)
|
|
}
|
|
}
|