package apply import ( "context" "fmt" "os" "os/exec" "os/user" "path/filepath" "slices" "sort" "strconv" "strings" "sync" "time" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) // A read-write access grants the path's own group (novox/hq ADR 0312, issue 537). // // **An access is not owning.** ADR 0051 left the operator's shared paths exactly as they are: the engine // confirms one is there and mounts it, and sets no owner, group or mode on it or below it. Nothing decided how // a `read-write` access then becomes writable, and a module whose identity was not the path's owner could not // write it at all — a download client could not remove a folder it had filed (issue 535). // // **The path's group is the operator's statement of who may share it.** So the engine reads, at apply, which // group owns the accessed path — only the machine knows — and makes the identities the controller names for // the access members of it: each container that mounts it gains the group as a supplementary group of its // process (`--group-add`, by number, because a container's own group database is not the machine's), and the // account that runs the module's tools is put in it as an account group (ADR 0252 §1). The module then writes // the path as its group may, and as nothing else. // // **The path is never changed** (§2). A group that cannot write the path is the operator's choice, as the data // is: the access grants nothing, is stated unhealthy on every look, saying so and that the operator decides, and // nothing is done to make it writable. **A read access grants nothing** (§3). **The root group is never granted** (§4): a // path owned by group 0, or by a group whose members act as root (accounts.RootGroups, the account judge's own // closed list, judged by the numbers those groups have here), or by a number the machine names nothing for, is // refused by name. // // **What was granted is taken back as ADR 0252 takes back** (§5): the access records each group it put the // account in that the account was not in before; when no declared resource asks for it any more, the account // leaves it, and a group the account held before is never recorded and never taken back. A container needs no // record: the group is part of what it is created as, so one no longer granted is recreated without it. // // **And what runs the code is restarted to hold it** (§6). A container is recreated with the group, because // its spec carries it. The tool runner — a process of the machine's own service manager with `User=` — is // restarted because the controller names the access under its `restart-on`, and the access is reported // updated exactly when the account's groups moved: that manager reads the account's groups from the database // each time it starts the unit, so the restarted process holds the group with no new login. A unit of the // account's own manager, or a login session, keeps the groups its login began with: the account judge // (internal/accounts) says *relogin needed* there, for the access's account as for a user resource's. // PathGroup is the group that owns an accessed path, as the engine reads it. type PathGroup struct { GID int // Name is the group's name in the machine's group database; empty when the database names nothing for // the number. Name string // Writable is whether the group's write bit is set on the path itself. Writable bool } // Words names the group for a person: its name and number, or its number alone. func (g PathGroup) Words() string { if g.Name == "" { return fmt.Sprintf("gid %d", g.GID) } return fmt.Sprintf("%s (gid %d)", g.Name, g.GID) } // GroupOfPath reads which group owns a path, by its number, its name and whether it may write. Reads only. A // variable so a test can stand in for a machine whose groups it cannot make. var GroupOfPath = func(path string) (PathGroup, error) { info, err := os.Stat(path) if err != nil { return PathGroup{}, err } _, gid, ok := statOwner(info) if !ok { return PathGroup{}, fmt.Errorf("the group that owns %s cannot be read on this machine", path) } g := PathGroup{GID: gid, Writable: info.Mode().Perm()&0o020 != 0} if found, err := user.LookupGroupId(strconv.Itoa(gid)); err == nil { g.Name = found.Name } return g, nil } // RootGroupIDs is the number of every group whose members act as root (accounts.RootGroups), as this machine // numbers them: read from /etc/group itself and from the machine's name service (`getent group`), since the engine // is built without cgo and Go's own lookup reads /etc/group alone — a group a name service holds, or one number // carrying several names, would otherwise pass a check made by name. Read at most once a minute, every look and // apply judging against the same answer, and the name service given five seconds in all. A variable so a test can // stand in. var RootGroupIDs = func() map[int]string { rootIDs.Lock() defer rootIDs.Unlock() if rootIDs.ids == nil || time.Since(rootIDs.at) > time.Minute { rootIDs.ids, rootIDs.at = readRootGroupIDs(), time.Now() } return rootIDs.ids } var rootIDs struct { sync.Mutex ids map[int]string at time.Time } func readRootGroupIDs() map[int]string { out := map[int]string{} if raw, err := os.ReadFile("/etc/group"); err == nil { for name, gid := range groupEntries(string(raw)) { if slices.Contains(accounts.RootGroups, name) { out[gid] = name } } } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for _, name := range accounts.RootGroups { raw, err := exec.CommandContext(ctx, "getent", "group", name).Output() if err != nil { continue } for found, gid := range groupEntries(string(raw)) { if found == name { out[gid] = name } } } return out } // groupEntries reads group database lines, name:password:gid:members, as name to number. func groupEntries(raw string) map[string]int { out := map[string]int{} for _, line := range strings.Split(raw, "\n") { fields := strings.Split(strings.TrimSpace(line), ":") if len(fields) < 3 || strings.HasPrefix(fields[0], "#") { continue } if gid, err := strconv.Atoi(fields[2]); err == nil { out[fields[0]] = gid } } return out } // GrantRefusal is why a path's group is never granted (novox/hq ADR 0312 §4), or nil when it may be: group 0, a // group whose members act as root by membership alone — judged by its NUMBER against every number those groups // have here, and by its name — or a group this machine names nothing for, which cannot be judged at all. func GrantRefusal(g PathGroup) error { const never = "a shared path is never a way to root (novox/hq ADR 0312 §4); nothing was granted, and nothing " + "was changed on it" if g.GID == 0 { return fmt.Errorf("it is owned by group 0 (%s), and a read-write access never grants the root group: %s", orRoot(g.Name), never) } if root, is := RootGroupIDs()[g.GID]; is { return fmt.Errorf("it is owned by gid %d, which is %s here, whose members act as root, and a read-write "+ "access never grants such a group: %s", g.GID, root, never) } if slices.Contains(accounts.RootGroups, g.Name) { return fmt.Errorf("it is owned by the group %s, whose members act as root, and a read-write access never "+ "grants such a group: %s", g.Words(), never) } if g.Name == "" { return fmt.Errorf("it is owned by gid %d, which this machine's group database names nothing, so whether "+ "its members act as root cannot be judged, and an unjudged group is never granted: %s", g.GID, never) } return nil } // CannotWriteWords is the reason an access whose group may not write its path is stated unhealthy with (novox/hq // ADR 0312 §2). Never the path: the controller says a reason on the operator's channel, which withholds a path // (ADR 0234 §6), and keeps the target as evidence. func CannotWriteWords(g PathGroup) string { return fmt.Sprintf("its group cannot write it: %s owns it without group write, and the mesh changes nothing on "+ "an accessed path (novox/hq ADR 0312 §2) — whether its group may write is the operator's to decide", g.Words()) } func orRoot(name string) string { if name == "" { return "root" } return name } // grant is what one read-write access grants, read once per apply. type grant struct { path string group PathGroup // refused is why nothing is granted: the path could not be read, or its group is never granted. refused error } // grants says the group is given: it may be granted, and it may write the path. A group that cannot write is // granted nothing (novox/hq ADR 0312 §2) — the access is stated unhealthy for the operator to decide. func (g grant) grants() bool { return g.refused == nil && g.group.Writable } // grantsOf reads the group of every read-write access that names a grant, by access id. One that is refused // grants nothing — the access itself fails, saying why — and one whose path the engine refuses to place // anything at (issue 339) is not read at all. func grantsOf(resources []declaration.Resource, refused map[string]error) map[string]grant { out := map[string]grant{} for _, r := range resources { a, ok := r.(*declaration.Access) if !ok || a.Grant == nil || a.Mode != declaration.AccessReadWrite { continue } if why := refused[filepath.Clean(a.Path)]; why != nil { out[a.ID] = grant{path: a.Path, refused: why} continue } g, err := GroupOfPath(a.Path) if err == nil { err = GrantRefusal(g) } out[a.ID] = grant{path: a.Path, group: g, refused: err} } return out } // containerGroups is, for each container, the number of every group a read-write access grants it, sorted and // once each: what it is created with as supplementary groups. func containerGroups(resources []declaration.Resource, grants map[string]grant) map[string][]int { out := map[string][]int{} for _, r := range resources { a, ok := r.(*declaration.Access) if !ok || a.Grant == nil { continue } g, granted := grants[a.ID] if !granted || !g.grants() { continue } for _, id := range a.Grant.Containers { if !slices.Contains(out[id], g.group.GID) { out[id] = append(out[id], g.group.GID) } } } for id := range out { sort.Ints(out[id]) } return out } // ContainerGroupsOf is containerGroups for a whole declaration, read now: what the scheduler arms a scheduled // step with, so a fire runs with the groups an apply would have created it with. A path issue 339's guard refuses // is not read, as the apply reads none. func ContainerGroupsOf(d *declaration.Declaration) map[string][]int { if d == nil { return nil } return containerGroups(d.Resources, grantsOf(d.Resources, refusedPlaces(d.Resources))) } // withGrants adds to what the declaration asks of each account the groups its read-write accesses grant (novox/hq // ADR 0312 §5): one user resource or access going leaves an account in a group another still asks for. func (w Wanted) withGrants(resources []declaration.Resource, grants map[string]grant) Wanted { for _, r := range resources { a, ok := r.(*declaration.Access) if !ok || a.Grant == nil || a.Grant.Account == "" { continue } g, granted := grants[a.ID] if !granted || !g.grants() { continue } if w[a.Grant.Account] == nil { w[a.Grant.Account] = map[string][]string{} } w[a.Grant.Account][g.group.Name] = append(w[a.Grant.Account][g.group.Name], a.ID) } return w } // GrantedAccounts is every account a read-write access puts in its path's group, as the account judge reads // them (novox/hq ADR 0312 §6): judged like a user resource's account groups, so a session of the account's own // manager that began before is said *relogin needed*, and only there. Read now; a path whose group is refused // or cannot be read asks nothing of the judge — the access says that itself. func GrantedAccounts(d *declaration.Declaration, held map[string]bool) []accounts.Account { if d == nil { return nil } grants := grantsOf(d.Resources, refusedPlaces(d.Resources)) var out []accounts.Account for _, r := range d.Resources { a, ok := r.(*declaration.Access) if !ok || a.Grant == nil || a.Grant.Account == "" || held[a.ID] { continue } g, granted := grants[a.ID] if !granted || !g.grants() { continue } at := strings.LastIndex(a.ID, ".") if at <= 0 || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) { continue } out = append(out, accounts.Account{Module: a.ID[:at], ID: a.ID, Name: a.Grant.Account, Groups: []string{g.group.Name}}) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } // grantAccess is applyAccess's grant (novox/hq ADR 0312): the account put in the path's group, what an earlier // apply of this access put an account in and nobody asks for any more taken back, and the outcome said. The // path itself is only read. func grantAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied, in inputs, out *Outcome) error { var g grant granting := r.Grant != nil && r.Mode == declaration.AccessReadWrite if granting { var read bool g, read = in.grants[r.ID] if !read { // An apply that read nothing ahead — a caller with no inputs — reads it here. g = grantsOf([]declaration.Resource{r}, in.refused)[r.ID] } if g.refused != nil { return fmt.Errorf("%s grants nothing: %w", r.Path, g.refused) } } // A group that cannot write is granted nothing (§2): what an earlier apply granted is taken back below. account := "" if granting && g.group.Writable { account = r.Grant.Account } if account != "" && g.group.Name == "" { return fmt.Errorf("%s is owned by %s, which this machine's group database names nothing, and an account "+ "is put in a group by name: %s was not (novox/hq ADR 0312 §1)", r.Path, g.group.Words(), account) } var said []string moved := false // What this access put an account in before. An access whose account changed gives the old one back // everything it put it in; the new one starts with nothing recorded. recorded := previous.Groups if previous.User != "" && previous.User != account { gave, words := takeBack(ctx, sys, previous.User, r.ID, previous.Groups, nil, run, in.groups) moved = moved || gave said = append(said, words...) recorded = nil } var kept []string if account != "" { groups, err := system.GroupsOf(ctx, system.Runner(run), account) if err != nil { return err } inGroup := setOf(groups)[g.group.Name] if !inGroup { if err := sys.AddUserToGroup(ctx, system.Runner(run), account, g.group.Name); err != nil { return err } moved = true said = append(said, fmt.Sprintf("put %s in %s, the group that owns it; the tool runner holds it once "+ "restarted, a session that began before only after a new login", account, g.group.Name)) } // Recorded only when the mesh put it there, now or before, or a record that went handed it over: a group // held before is never taken back. if !inGroup || slices.Contains(recorded, g.group.Name) || in.handed.has(account, g.group.Name) { kept = append(kept, g.group.Name) } var others []string for _, x := range recorded { if x != g.group.Name { others = append(others, x) } } gave, words := takeBack(ctx, sys, account, r.ID, others, &kept, run, in.groups) moved = moved || gave said = append(said, words...) } if len(kept) > 0 { out.groups, out.user = kept, account } if granting { var to []string if len(r.Grant.Containers) > 0 { to = append(to, "its containers "+strings.Join(r.Grant.Containers, ", ")) } if account != "" { to = append(to, account) } switch { case !g.group.Writable: out.Detail = joinDetail(out.Detail, fmt.Sprintf("its group %s cannot write it, so nothing is granted: "+ "stated unhealthy, and nothing was changed on it — the operator decides", g.group.Words())) case len(to) > 0: out.Detail = joinDetail(out.Detail, fmt.Sprintf("grants %s to %s", g.group.Words(), strings.Join(to, " and "))) } } if len(said) > 0 { out.Detail = joinDetail(out.Detail, strings.Join(said, "; ")) } if moved { // Updated exactly when the account's groups moved: what names this access under restart-on — the // tool runner — is restarted to hold them (novox/hq ADR 0312 §6). out.Action = "updated" } return nil } // takeBack takes account out of every group of groups the mesh put it in for the resource id, unless another // declared resource still asks for it — then it stays, and stays recorded on keep when keep is given, so it is // taken back once nobody asks. A group it is no longer in is nothing to give back. Never fatal: a failure is // said, and the group stays recorded where it can be. Whether any group was taken back, and what was said. func takeBack(ctx context.Context, sys system.System, account, id string, groups []string, keep *[]string, run Runner, wanted Wanted) (bool, []string) { if len(groups) == 0 { return false, nil } in, err := system.GroupsOf(ctx, system.Runner(run), account) if err != nil { if keep != nil { *keep = append(*keep, groups...) } return false, []string{fmt.Sprintf("the groups the mesh put %s in (%s) were not given back: %v", account, strings.Join(groups, ", "), err)} } already := setOf(in) gave := false var said []string for _, g := range groups { if !already[g] { continue } if others := wanted.othersAsk(account, g, id); len(others) > 0 { said = append(said, fmt.Sprintf("left %s in %s, which %s still asks for", account, g, strings.Join(others, ", "))) if keep != nil { *keep = append(*keep, g) } continue } gone, why := leaveGroup(ctx, sys, account, g, run) if !gone && keep != nil { *keep = append(*keep, g) } gave = gave || gone said = append(said, account+" "+why) } return gave, said } // removeAccess is an access no longer declared: the path left exactly as it is (novox/hq ADR 0051), and every // group it put an account in taken back unless another declared resource still asks for it (ADR 0312 §5) — then // handed to that resource, which records it as the mesh's and takes it back when it stops asking. func removeAccess(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted, handed Handed) (string, string) { const left = "an operator-owned path is never the host's to remove" if a.User == "" || len(a.Groups) == 0 { return "forgotten", left } var stays []string gave, said := takeBack(ctx, sys, a.User, a.ID, a.Groups, &stays, run, wanted) for _, g := range stays { if len(wanted.othersAsk(a.User, g, a.ID)) > 0 { handed.hand(a.User, g) } } action := "forgotten" if gave { action = "restored" } return action, joinDetail(left, strings.Join(said, "; ")) } // groupAddArgs is the runtime's arguments that give a container's process each granted group as a supplementary // group, by number: the container's own group database is not the machine's, and a name could mean another // group there, or none. func groupAddArgs(groups []int) []string { var args []string for _, gid := range groups { args = append(args, "--group-add", strconv.Itoa(gid)) } return args } func gidWords(groups []int) string { words := make([]string, 0, len(groups)) for _, gid := range groups { words = append(words, strconv.Itoa(gid)) } return strings.Join(words, ", ") } // Handed is every group a record that went in this apply put an account in, and another declared resource still // asks for: account → group. The resource that still asks records it as the mesh's when it is applied, so the group // is taken back once nobody asks for it any more, rather than staying for ever because the record that put it // there went first (novox/hq ADR 0252 §3, ADR 0312 §5). type Handed map[string]map[string]bool func (h Handed) hand(account, group string) { if h == nil { return } if h[account] == nil { h[account] = map[string]bool{} } h[account][group] = true } func (h Handed) has(account, group string) bool { return h[account][group] }