Files
mesh-host/internal/apply/grants.go
T
jschoubben f4efc3cad0 Grant a read-write access's path group to what runs the module's code, so a module can write shared data without the mesh changing it (hq ADR 0312, issue 537)
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`.
2026-10-12 12:23:38 +02:00

408 lines
16 KiB
Go

package apply
import (
"context"
"fmt"
"os"
"os/user"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"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 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), 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
}
// GrantRefusal is why a path's group is never granted (novox/hq ADR 0312 §4), or nil when it may be: group 0,
// or a group whose members act as root by membership alone.
func GrantRefusal(g PathGroup) error {
switch {
case g.GID == 0:
return fmt.Errorf("it is owned by group 0 (%s), and a read-write access never grants the root group: a "+
"shared path is never a way to root (novox/hq ADR 0312 §4); nothing was granted, and nothing was "+
"changed on it", orRoot(g.Name))
case 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: a shared path is never a way to root (novox/hq ADR 0312 §4); nothing was "+
"granted, and nothing was changed on it", g.Words())
}
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
}
// 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.refused != nil {
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.
func ContainerGroupsOf(d *declaration.Declaration) map[string][]int {
if d == nil {
return nil
}
return containerGroups(d.Resources, grantsOf(d.Resources, nil))
}
// 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.refused != nil || g.group.Name == "" {
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, nil)
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.refused != nil || g.group.Name == "" {
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)
}
}
account := ""
if granting {
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: a group held before is never taken back.
if !inGroup || slices.Contains(recorded, 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)
}
if len(to) > 0 {
out.Detail = joinDetail(out.Detail, fmt.Sprintf("grants %s to %s", g.group.Words(), strings.Join(to, " and ")))
}
if !g.group.Writable {
out.Detail = joinDetail(out.Detail, "its group cannot write it: stated unhealthy, and nothing was changed "+
"on it — the operator decides")
}
}
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).
func removeAccess(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted) (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
}
gave, said := takeBack(ctx, sys, a.User, a.ID, a.Groups, nil, run, wanted)
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, ", ")
}