Files
mesh-host/internal/apply/grants.go
T
jschoubben c433e76de2
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Judge a granted group by number, grant nothing a group cannot write, and keep the look bounded (hq ADR 0312, issue 537 review)
The engine is built without cgo, so a check by name missed a root group a name
service holds or one number carrying another name: the root groups are resolved
to numbers (now with kmem and shadow) and a number named nothing is refused for
containers too. A group without write is granted nothing (§2). The look reads
each path off the lock within five seconds, so a dead network mount cannot stop
every health statement. A grant's account may not start with a dash. A group a
record leaves for another asker is handed to it, so it is still taken back when
the last asker goes (§5). The scheduler skips paths issue 339's guard refuses.
2026-10-12 12:53:42 +02:00

515 lines
20 KiB
Go

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] }