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 }) rootGroupsAre(t, map[int]string{0: "root", 10: "wheel", 968: "docker"}) } // rootGroupsAre stands in for the numbers this machine gives the groups whose members act as root. func rootGroupsAre(t *testing.T, ids map[int]string) { t.Helper() was := RootGroupIDs RootGroupIDs = func() map[int]string { return ids } t.Cleanup(func() { RootGroupIDs = 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 TestAnAccessWhoseGroupCannotWriteGrantsNothingAndSaysTheOperatorDecides(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, "cannot write it, so nothing is granted") || !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) } // Nothing is given (ADR 0312 §2): no account group, no container group. if strings.Contains(m.groups(), "downloads") { t.Errorf("a group that cannot write was granted to the account: %q", m.groups()) } if run := strings.Join(m.runs["nzbget-app"], " "); strings.Contains(run, "--group-add") { t.Errorf("a group that cannot write was granted to the container: %s", run) } 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}, {GID: 42, Name: "shadow", Writable: true}, // A number the root groups have here under another name — judged by the number, never the name. {GID: 968, Name: "containers", Writable: true}, // A number the machine names nothing for: unjudged, so never granted, to a container either. {GID: 4242, Name: "", Writable: true}} { want := g.Name if want == "" || want == "containers" { want = fmt.Sprintf("gid %d", g.GID) } t.Run(fmt.Sprintf("%d %s", g.GID, 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(), want) || !strings.Contains(err.Error(), "never") { t.Errorf("the refusal does not name the access and the group: %v", err) } if g.Name != "" && 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) } } // An access going while a user resource still asks for its group hands the group over: the user resource records it // as the mesh's, and takes it back when it stops asking too (novox/hq ADR 0312 §5, ADR 0252 §3). func TestAGroupStillAskedForIsHandedOverAndTakenBackWhenTheLastAskerGoes(t *testing.T) { music := t.TempDir() groupOfPaths(t, map[string]PathGroup{music: {GID: 1001, Name: "media", Writable: true}}) player := `{"id":"player.account","type":"user","name":"operator","groups":["media"]}` m := newGrantMachine("operator", []string{"operator"}, "media") _, state, err := applyGrants(t, m, store.State{}, accessGranting("importer.music", music, "operator"), player) if err != nil { t.Fatal(err) } if rec, _ := state.Find("player.account"); len(rec.Groups) != 0 { t.Fatalf("the user resource recorded a group the access put the account in: %+v", rec) } _, state, err = applyGrants(t, m, state, player) if err != nil { t.Fatal(err) } if !strings.Contains(m.groups(), "media") { t.Fatalf("a group the user resource still asks for was taken back: %q", m.groups()) } if rec, _ := state.Find("player.account"); strings.Join(rec.Groups, " ") != "media" { t.Fatalf("the group was not handed to the user resource that still asks for it: %+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("the group stayed after the last resource asking for it went: %q", m.groups()) } } // A grant's account is an argument to id, usermod and gpasswd, and a name that starts with a dash would be read as // an option by each. func TestAGrantsAccountMayNotReadAsAnOption(t *testing.T) { for _, name := range []string{"-ogroups", "--help", "a b", "a:b"} { _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.lib","type":"access","path":"/srv/lib",` + `"mode":"read-write","grant":{"account":"` + name + `"}}]}`)) if err == nil || !strings.Contains(err.Error(), "not starting with a dash") { t.Errorf("the account %q was accepted: %v", name, err) } } if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.lib","type":"access","path":"/srv/lib",` + `"mode":"read-write","grant":{"account":"jochen"}}]}`)); err != nil { t.Errorf("an ordinary account was refused: %v", err) } } // The scheduler reads no path issue 339's guard refuses, as the apply reads none. func TestAScheduledStepIsArmedWithoutReadingARefusedPath(t *testing.T) { var read []string was := GroupOfPath GroupOfPath = func(path string) (PathGroup, error) { read = append(read, path) return PathGroup{GID: 1001, Name: "media", Writable: true}, nil } t.Cleanup(func() { GroupOfPath = was }) rootGroupsAre(t, map[int]string{0: "root"}) d := parse(t, `{"declaration":1,"resources":[`+accessGranting("m.etc", "/etc", "", "m.step")+`,`+ `{"id":"m.step","type":"container","name":"step","image":"`+pinned+`","schedule":"0 3 * * *","volumes":["/etc:/x"]}]}`) if got := ContainerGroupsOf(d); len(got["m.step"]) != 0 { t.Errorf("a step was armed with the group of a refused path: %v", got) } if len(read) != 0 { t.Errorf("the scheduler read a path the guard refuses: %v", read) } } // The group database is read as name to number, so a number carrying a root group's name under any other name // is still that number. func TestGroupEntriesAreReadByNumber(t *testing.T) { got := groupEntries("root:x:0:\n# a comment\ndocker:x:968:operator\ncontainers:x:968:\nbroken\n") if got["root"] != 0 || got["docker"] != 968 || got["containers"] != 968 || len(got) != 3 { t.Errorf("read %v", got) } }