Files
mesh-host/internal/apply/identities.go
T
jschoubben 2a2be687f3 Keep libraries under groups of their own, and give modules users in them (hq ADR 0328, issue 567)
ADR 0328 supersedes ADR 0325: shared data is a library with one home, kept usable by
its group rather than by ACL entries. The engine now makes each library's group and
each module's user in its libraries' groups; keeps a library's root and requested
sub-paths in its group with group write and setgid on its home, making missing
sub-paths as the root is; waits for the operator's word before touching a root that
holds data, and on the take act walks it by descriptor (no link, same filesystem,
bounded and resumed, files with other names left); holds the containers of a library
whose disk or mount is not there; and puts the tool runner's account in the group.
The ACL keeping, mask repair and watch are gone; an access is again as before ADR 0325.
2026-10-12 22:11:09 +02:00

383 lines
15 KiB
Go

package apply
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// Every module is given an identity of its own (novox/hq ADR 0328 §3, §4, §15, issue 567).
//
// The controller allocates each module a user and a group number once, from a range of its own, the same on every
// machine, and declares them as `mesh-<module>`. The engine makes the group and the account with exactly those
// numbers — a system account: no login shell, no home, no password — before anything of the declaration is
// applied, so a directory the module owns can be given to it and a container told to run as it.
//
// **Never root** (§11): a number that is 0, or a gid a root group has here (by its number and by its name), is
// refused for the whole declaration before anything is made or started, naming why — for an account and for an
// image alike. **Never another's**: a name that exists with other numbers, or numbers another name holds, is
// refused for that identity alone, naming whose, and its module is not attempted; the rest of the machine is.
//
// **What it made, and only that, goes** when the identity is no longer declared: the record says whether the
// engine made the account, the group or both, and an account or a group that was there before is never removed.
// accountsTool is how the engine reads and changes the machine's accounts: the system's own tools through the
// runner, which is what a test stands in for.
type accountsTool interface {
CreateSystemAccount(ctx context.Context, run system.Runner, name string, uid, gid int, groups []string) error
ModifyAccount(ctx context.Context, run system.Runner, name string, gid int, groups []string) error
CreateSystemGroup(ctx context.Context, run system.Runner, name string, gid int) error
RemoveAccount(ctx context.Context, run system.Runner, name string) error
RemoveGroup(ctx context.Context, run system.Runner, name string) error
}
// entry is one line the user or group database gives for a key: its name and its numbers.
type entry struct {
name string
id, prim int
}
// lookUp asks the database (passwd or group) for key, a name or a number. Absent is (entry{}, false, nil); a
// database that does not answer is an error, never read as absent (system.LookUpUser's rule).
func lookUp(ctx context.Context, run Runner, database, key string) (entry, bool, error) {
out, err := run(ctx, "getent", database, key)
if err != nil {
if code, ok := system.ExitCode(err); ok && code == 2 {
return entry{}, false, nil
}
return entry{}, false, fmt.Errorf("the %s database did not answer about %s: %w", database, key, err)
}
fields := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ":")
if len(fields) < 3 {
return entry{}, false, fmt.Errorf("the %s database gave %q for %s", database, strings.TrimSpace(out), key)
}
e := entry{name: fields[0], prim: -1}
if e.id, err = strconv.Atoi(fields[2]); err != nil {
return entry{}, false, fmt.Errorf("the %s database gave %q for %s", database, strings.TrimSpace(out), key)
}
if database == "passwd" && len(fields) >= 4 {
if e.prim, err = strconv.Atoi(fields[3]); err != nil {
e.prim = -1
}
}
return e, true, nil
}
// refuseRootIdentities is every identity and every container's run-as that is root's (§11), judged before
// anything of the declaration is applied: one is enough to refuse it whole, so nothing is made or started.
func refuseRootIdentities(d *declaration.Declaration) error {
var problems []string
for _, id := range d.Identities {
// Its user's number, its own group's (the same number), its primary group and every added group.
if err := rootIdentity(id.UID, id.UID); err != nil {
problems = append(problems, fmt.Sprintf("the identity %s: %v", id.Name, err))
}
for _, gid := range append([]int{id.GID}, id.Groups...) {
if why := rootGroup(gid); why != "" {
problems = append(problems, fmt.Sprintf("the identity %s is given gid %s, and a module's identity "+
"is never in a root group (novox/hq ADR 0328 §15)", id.Name, why))
}
}
}
for _, g := range d.Groups {
if why := rootGroup(g.GID); why != "" {
problems = append(problems, fmt.Sprintf("the library group %s is gid %s, and a library's group is never a "+
"root group (novox/hq ADR 0328 §15)", g.Name, why))
}
}
for _, r := range d.Resources {
if c, ok := r.(*declaration.Container); ok && c.RunAs != nil {
if err := rootIdentity(c.RunAs.UID, c.RunAs.GID); err != nil {
problems = append(problems, fmt.Sprintf("the container %s is told to run as uid %d, gid %d: %v",
c.ID, c.RunAs.UID, c.RunAs.GID, err))
}
}
}
if len(problems) == 0 {
return nil
}
sort.Strings(problems)
return fmt.Errorf("%s; nothing of this declaration was made or started", strings.Join(problems, "; "))
}
// applyIdentity makes one identity as declared (ADR 0328 §4): its own group `mesh-<module>` with the number of its
// uid, and its user with that uid, its primary group gid (its own, or its first library's) and its other libraries'
// groups as added groups — or refuses it naming whose the name or the numbers are. A user the engine made is given
// a changed primary or added groups; one that was there before is never changed, and is refused if it is not as
// declared. What it made is recorded on known; an identity already right is unchanged.
func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, names map[int]string,
known *store.State, run Runner) (Outcome, error) {
out := Outcome{ID: "identity " + id.Name, Type: "identity", Target: id.Name, Action: "unchanged"}
user, hasUser, err := lookUp(ctx, run, "passwd", id.Name)
if err != nil {
return out, err
}
group, hasGroup, err := lookUp(ctx, run, "group", id.Name)
if err != nil {
return out, err
}
byUID, uidUsed, err := lookUp(ctx, run, "passwd", strconv.Itoa(id.UID))
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.UID))
if err != nil {
return out, err
}
rec := madeIdentity(known, id.Name)
const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0328 §4)"
switch {
case hasUser && user.id != id.UID:
return out, fmt.Errorf("the account %s is here with uid %d, and the mesh allocated uid %d%s", id.Name,
user.id, id.UID, never)
case hasUser && user.prim != id.GID && !rec.MadeUser:
return out, fmt.Errorf("the account %s is here with group %d, was not made by the mesh, and the mesh "+
"gives it group %d%s", id.Name, user.prim, id.GID, never)
case hasGroup && group.id != id.UID:
return out, fmt.Errorf("the group %s is here with gid %d, and a module's own group has its uid, %d%s",
id.Name, group.id, id.UID, never)
case uidUsed && byUID.name != id.Name:
return out, fmt.Errorf("uid %d is the account %s's here, and the mesh allocated it to %s%s", id.UID,
byUID.name, id.Name, never)
case gidUsed && byGID.name != id.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.UID,
byGID.name, id.Name, never)
}
added := make([]string, 0, len(id.Groups))
for _, gid := range id.Groups {
added = append(added, names[gid])
}
sort.Strings(added)
rec.Module, rec.Name, rec.UID, rec.GID = id.Module, id.Name, id.UID, id.GID
var made []string
if !hasGroup {
if err := tool.CreateSystemGroup(ctx, run, id.Name, id.UID); err != nil {
return out, err
}
rec.MadeGroup = true
made = append(made, fmt.Sprintf("its own group (gid %d)", id.UID))
}
if !hasUser {
if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID, added); err != nil {
recordIdentity(known, rec)
return out, err
}
rec.MadeUser = true
made = append(made, fmt.Sprintf("the account (uid %d, group %d%s), with no login, no home and no password",
id.UID, id.GID, alsoIn(added)))
} else if rec.MadeUser {
// The mesh's own account: its primary and added groups follow what is declared.
in, err := system.GroupsOf(ctx, system.Runner(run), id.Name)
if err != nil {
return out, err
}
have := map[string]bool{}
for _, g := range in {
if g != id.Name && g != names[user.prim] {
have[g] = true
}
}
same := user.prim == id.GID && len(have) == len(added)
for _, g := range added {
same = same && have[g]
}
if !same {
if err := tool.ModifyAccount(ctx, run, id.Name, id.GID, added); err != nil {
return out, err
}
out.Action = "updated"
out.Detail = fmt.Sprintf("gave the account group %d%s", id.GID, alsoIn(added))
}
}
if rec.At.IsZero() {
rec.At = time.Now().UTC()
}
recordIdentity(known, rec)
if len(made) > 0 {
out.Action, out.Detail = "created", "made "+strings.Join(made, " and ")
} else if out.Detail == "" {
out.Detail = "here with its numbers"
}
return out, nil
}
func alsoIn(groups []string) string {
if len(groups) == 0 {
return ""
}
return ", also in " + strings.Join(groups, ", ")
}
// applyGroup makes one library's group with the number the controller allocated (ADR 0328 §3), or refuses it naming
// whose the name or the number is, or that the number is a root group's (§15). What it made is recorded on known.
func applyGroup(ctx context.Context, tool accountsTool, g declaration.Group, known *store.State,
run Runner) (Outcome, error) {
out := Outcome{ID: "group " + g.Name, Type: "group", Target: g.Name, Action: "unchanged"}
if why := rootGroup(g.GID); why != "" {
return out, fmt.Errorf("its gid is %s, and a library's group is never a root group (novox/hq ADR 0328 §15); "+
"nothing was made", why)
}
byName, hasName, err := lookUp(ctx, run, "group", g.Name)
if err != nil {
return out, err
}
byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(g.GID))
if err != nil {
return out, err
}
switch {
case hasName && byName.id != g.GID:
return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d; nothing was made",
g.Name, byName.id, g.GID)
case gidUsed && byGID.name != g.Name:
return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s; nothing was made",
g.GID, byGID.name, g.Name)
case hasName:
out.Detail = "here with its number"
return out, nil
}
if err := tool.CreateSystemGroup(ctx, run, g.Name, g.GID); err != nil {
return out, err
}
known.Groups = append(known.Groups, store.MadeGroup{Name: g.Name, GID: g.GID, At: time.Now().UTC()})
out.Action, out.Detail = "created", fmt.Sprintf("made the library's group (gid %d)", g.GID)
return out, nil
}
// removeGroups removes every library group the engine made that is no longer declared, while it still has the
// number the engine gave it. Never fatal: one that could not be removed (still a user's primary group) stays
// recorded, said, for the next apply.
func removeGroups(ctx context.Context, tool accountsTool, declared []declaration.Group, known *store.State,
run Runner) []Outcome {
wanted := map[string]bool{}
for _, g := range declared {
wanted[g.Name] = true
}
var out []Outcome
var keep []store.MadeGroup
for _, m := range known.Groups {
if wanted[m.Name] {
keep = append(keep, m)
continue
}
o := Outcome{ID: "group " + m.Name, Type: "group", Target: m.Name, Action: "removed",
Detail: "no longer declared: removed the group"}
g, has, err := lookUp(ctx, run, "group", m.Name)
switch {
case err != nil || (has && g.id != m.GID):
o.Action, o.Detail = "forgotten", "no longer declared: the group was left, it no longer has the gid the "+
"mesh gave it, or the database did not answer"
case has:
if err := tool.RemoveGroup(ctx, run, m.Name); err != nil {
o.Action, o.Detail = "waiting", "no longer declared: the group could not be removed: "+err.Error()
keep = append(keep, m)
}
default:
o.Action, o.Detail = "forgotten", "no longer declared, and no longer here"
}
out = append(out, o)
}
known.Groups = keep
return out
}
// madeIdentity is what the engine recorded of name, or an empty record.
func madeIdentity(known *store.State, name string) store.MadeIdentity {
for _, m := range known.Identities {
if m.Name == name {
return m
}
}
return store.MadeIdentity{}
}
// recordIdentity records what was made of one identity, in place of any record before.
func recordIdentity(known *store.State, rec store.MadeIdentity) {
for i, m := range known.Identities {
if m.Name == rec.Name {
known.Identities[i] = rec
return
}
}
if rec.MadeUser || rec.MadeGroup {
known.Identities = append(known.Identities, rec)
}
}
// removeIdentities removes what the engine made of every identity no longer declared — the account, then the
// group — and only while it still has the numbers the engine gave it. Never fatal: what could not be removed
// stays recorded, said, for the next apply.
func removeIdentities(ctx context.Context, tool accountsTool, declared []declaration.Identity, known *store.State,
run Runner) []Outcome {
wanted := map[string]bool{}
for _, id := range declared {
wanted[id.Name] = true
}
var out []Outcome
var keep []store.MadeIdentity
for _, m := range known.Identities {
if wanted[m.Name] {
keep = append(keep, m)
continue
}
o := Outcome{ID: "identity " + m.Name, Type: "identity", Target: m.Name, Action: "removed"}
var said []string
if m.MadeUser {
if u, has, err := lookUp(ctx, run, "passwd", m.Name); err != nil || (has && u.id != m.UID) {
said = append(said, "the account was left: it no longer has the uid the mesh gave it, or the "+
"database did not answer")
m.MadeUser = false
} else if has {
if err := tool.RemoveAccount(ctx, run, m.Name); err != nil {
said = append(said, "the account could not be removed: "+err.Error())
} else {
m.MadeUser = false
said = append(said, "removed the account")
}
} else {
m.MadeUser = false
}
}
if m.MadeGroup && !m.MadeUser {
if g, has, err := lookUp(ctx, run, "group", m.Name); err != nil || (has && g.id != m.GID) {
said = append(said, "the group was left: it no longer has the gid the mesh gave it, or the "+
"database did not answer")
m.MadeGroup = false
} else if has {
if err := tool.RemoveGroup(ctx, run, m.Name); err != nil {
said = append(said, "the group could not be removed: "+err.Error())
} else {
m.MadeGroup = false
said = append(said, "removed the group")
}
} else {
m.MadeGroup = false
}
}
if m.MadeUser || m.MadeGroup {
o.Action = "waiting"
keep = append(keep, m)
}
o.Detail = "no longer declared: " + strings.Join(said, "; ")
out = append(out, o)
}
known.Identities = keep
return out
}
// toolFor is the accounts tool of a system, or why it has none.
func toolFor(sys system.System) (accountsTool, error) {
if t, ok := sys.(accountsTool); ok {
return t, nil
}
return nil, errors.New("this system makes no module identity: it has no way to make a system account")
}