package apply import ( "context" "fmt" "strings" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) // An account's groups, from any module (novox/hq ADR 0252, issue 247). // // **A module that needs the account in a group declares the account with that group, and nothing else // of it.** The shell's module sets the account's shell; the lighting daemon's module puts the same // account in `openrazer`; the container runtime's in `docker`. A shell is one value and stays one // module's (the controller refuses two); a group is added, so several modules' groups never contradict. // // **The mesh takes back only what it gave.** A group the account was in before is the machine's or the // operator's, and stays when every resource that named it goes. A group the mesh put the account in is // recorded on the resource that put it there, and given back when that resource stops asking for it — // unless another declared resource still asks for the same group, which keeps it. // // **A group takes effect at the next login.** The machine's group database changes at once; every // process already running, the account's own service manager and everything it started included, keeps // the groups it started with. So the outcome says a new login is needed, and the node-engine's account // judge (internal/accounts) says so on every look until the account's running manager has the group. // Wanted is every group a declaration asks an account to be in, and which resources ask: account → // group → resource ids. type Wanted map[string]map[string][]string // groupsWanted reads every user resource's groups from a declaration. func groupsWanted(resources []declaration.Resource) Wanted { w := Wanted{} for _, r := range resources { u, ok := r.(*declaration.User) if !ok || u.Name == "" { continue } for _, g := range u.Groups { if w[u.Name] == nil { w[u.Name] = map[string][]string{} } w[u.Name][g] = append(w[u.Name][g], u.ID) } } return w } // othersAsk is every resource other than one that asks for an account to be in a group. func (w Wanted) othersAsk(account, group, except string) []string { var others []string for _, id := range w[account][group] { if id != except { others = append(others, id) } } return others } // applyGroups makes the account be in every group the resource declares, and takes it out of every group // this resource put it in and no longer asks for. What the mesh put the account in is recorded on out. func applyGroups(ctx context.Context, sys system.System, r *declaration.User, run Runner, previous store.Applied, wanted Wanted, out *Outcome) error { // What this resource put the account in before, for this account only: a declaration that renamed its // user says nothing about the new one's groups. var added []string if previous.Target == r.Name { added = append(added, previous.Groups...) } if len(r.Groups) == 0 && len(added) == 0 { return nil } in, err := system.GroupsOf(ctx, system.Runner(run), r.Name) if err != nil { return err } already := setOf(in) declared := setOf(r.Groups) var put []string for _, want := range r.Groups { if already[want] { continue } exists, err := system.GroupExists(ctx, system.Runner(run), want) if err != nil { return err } if !exists { return fmt.Errorf("%q was not put in the group %q: this machine has no such group yet. The package "+ "that makes it is not installed, or is declared after the account", r.Name, want) } if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil { return err } already[want] = true put = append(put, want) if !contains(added, want) { added = append(added, want) } } // What this resource put the account in and no longer asks for. var kept, said []string for _, g := range added { switch { case declared[g]: kept = append(kept, g) case !already[g]: // Taken out since, by a person: nothing to give back. case len(wanted.othersAsk(r.Name, g, r.ID)) > 0: said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, strings.Join(wanted.othersAsk(r.Name, g, r.ID), ", "))) default: gone, why := leaveGroup(ctx, sys, r.Name, g, run) if !gone { kept = append(kept, g) } said = append(said, why) } } out.groups = kept if len(put) > 0 { said = append([]string{fmt.Sprintf("put in %s; a session that began before has %s only after a new "+ "login", strings.Join(put, ", "), them(len(put)))}, said...) } if len(put) > 0 || len(said) > 0 { if out.Action == "unchanged" { out.Action = "updated" } out.Detail = joinDetail(out.Detail, strings.Join(said, "; ")) } return nil } // giveGroupsBack is removeUser's groups: every group the mesh put the account in, taken back unless // another declared resource still asks for it. Never fatal, for the shell's reason: a removal that failed // would stay recorded and fail the same way on every apply after. Empty when there was nothing to say. func giveGroupsBack(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted) (bool, string) { if len(a.Groups) == 0 { return false, "" } in, err := system.GroupsOf(ctx, system.Runner(run), a.Target) if err != nil { return false, fmt.Sprintf("the groups the mesh put it in (%s) were not given back: %v", strings.Join(a.Groups, ", "), err) } already := setOf(in) gave := false var said []string for _, g := range a.Groups { if !already[g] { continue } if others := wanted.othersAsk(a.Target, g, a.ID); len(others) > 0 { said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, strings.Join(others, ", "))) continue } gone, why := leaveGroup(ctx, sys, a.Target, g, run) gave = gave || gone said = append(said, why) } return gave, strings.Join(said, "; ") } // leaveGroup takes an account out of one group the mesh put it in, read back from the machine, and says // what came of it. func leaveGroup(ctx context.Context, sys system.System, account, group string, run Runner) (bool, string) { l, ok := sys.(system.GroupLeaver) if !ok { return false, fmt.Sprintf("left in %s: this machine's system cannot take an account out of one group", group) } if err := l.RemoveUserFromGroup(ctx, system.Runner(run), account, group); err != nil { return false, fmt.Sprintf("left in %s: %v", group, err) } in, err := system.GroupsOf(ctx, system.Runner(run), account) if err != nil { return false, fmt.Sprintf("taken out of %s, and the group database could not be read back: %v", group, err) } if setOf(in)[group] { return false, fmt.Sprintf("taken out of %s, and the group database still lists it there", group) } return true, fmt.Sprintf("taken out of %s, which the mesh had put it in", group) } func setOf(items []string) map[string]bool { s := map[string]bool{} for _, i := range items { s[i] = true } return s } func contains(items []string, want string) bool { for _, i := range items { if i == want { return true } } return false } func them(n int) string { if n == 1 { return "it" } return "them" } func joinDetail(a, b string) string { switch { case a == "": return b case b == "": return a } return a + "; " + b }