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`.
408 lines
16 KiB
Go
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, ", ")
|
|
}
|