Give the controller the contract it can fill (hq ADR 0325, issue 567)
The controller cannot know the operator's account's uid, names an access by its declared id (which holds a slash), and should not parse words. A tool runner's account may now come by name alone and is read here (refused when absent or root's, its uid said in the access's listing); the take act matches the declared id exactly; an access wait says its reason (keep or disk) and, for a filesystem that refuses ACLs, whether the take act can enable them.
This commit is contained in:
@@ -541,6 +541,8 @@ func ApplyMindingWindows(
|
||||
in.refused = refusedPlaces(d.Resources)
|
||||
// What every declared access asks of its folder, several accesses of one folder as one (novox/hq ADR 0325 §5),
|
||||
// and every accessed folder, which no container mounts while it is not there (§9).
|
||||
// Every tool runner's account that came with no number, read by name here (ADR 0325 §4).
|
||||
in.runners = ResolveRunners(ctx, d)
|
||||
in.folders = foldersOf(d.Resources, in.refused)
|
||||
in.identities = len(d.Identities) > 0
|
||||
in.accesses = accessPaths(d.Resources, in.refused, in.identities)
|
||||
@@ -2199,7 +2201,9 @@ type inputs struct {
|
||||
folders map[string]*folderWant
|
||||
// identities is that the declaration carries identities: its controller speaks ADR 0325 for every access.
|
||||
identities bool
|
||||
accesses map[string]string
|
||||
// runners is every access refused for its tool runner's account (not here, or root's), by id.
|
||||
runners map[string]error
|
||||
accesses map[string]string
|
||||
// handed is every group a record that went this apply handed to a resource still asking for it.
|
||||
handed Handed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The contract the controller needs (novox/hq ADR 0325, issue 567): what it cannot know is read here, and what it
|
||||
// names is matched as it names it.
|
||||
|
||||
// accountsHere stands in for the machine's user database: name → uid, gid.
|
||||
func accountsHere(t *testing.T, known map[string][2]int) {
|
||||
t.Helper()
|
||||
was := AccountIDs
|
||||
AccountIDs = func(_ context.Context, name string) (int, int, bool, error) {
|
||||
ids, ok := known[name]
|
||||
return ids[0], ids[1], ok, nil
|
||||
}
|
||||
t.Cleanup(func() { AccountIDs = was })
|
||||
}
|
||||
|
||||
// A tool runner's account comes with its name alone: its uid is read here, and its entry kept with it.
|
||||
func TestAToolRunnersAccountIsReadByName(t *testing.T) {
|
||||
dir := aclFolder(t)
|
||||
keeping(t, nil, nil)
|
||||
accountsHere(t, map[string][2]int{"nox": {runner, 1000}})
|
||||
lib := filepath.Join(dir, "books")
|
||||
if err := os.Mkdir(lib, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := `{"id":"importer.books","type":"access","path":"` + lib + `","mode":"read-write","users":[` +
|
||||
`{"identity":"nox","use":"read-write","from":"tool-runner"}]}`
|
||||
if _, _, err := applyAccesses(t, store.State{}, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effectiveOn(t, lib, runner); got != 7 {
|
||||
t.Fatalf("the tool runner's account read by name may %s", permWords(got))
|
||||
}
|
||||
if rec, _ := keptRecord(lib); len(rec.Want) != 1 || rec.Want[0].UID != runner {
|
||||
t.Errorf("the uid read is not what is kept: %+v", rec.Want)
|
||||
}
|
||||
}
|
||||
|
||||
// One that is not here, or is root's, refuses its access, naming why, and gives nothing an entry.
|
||||
func TestAToolRunnersAccountThatIsNotHereOrIsRootsIsRefused(t *testing.T) {
|
||||
was := RootGroupIDs
|
||||
RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} }
|
||||
defer func() { RootGroupIDs = was }()
|
||||
for name, says := range map[string]string{"ghost": "not on this machine", "root": "never root",
|
||||
"admin": "root group"} {
|
||||
dir := aclFolder(t)
|
||||
keeping(t, nil, nil)
|
||||
accountsHere(t, map[string][2]int{"root": {0, 0}, "admin": {1001, 10}})
|
||||
lib := filepath.Join(dir, "books")
|
||||
if err := os.Mkdir(lib, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := `{"id":"importer.books","type":"access","path":"` + lib + `","mode":"read-write","users":[` +
|
||||
`{"identity":"` + name + `","use":"read-write","from":"tool-runner"}]}`
|
||||
_, _, err := applyAccesses(t, store.State{}, a)
|
||||
if err == nil || !strings.Contains(err.Error(), says) {
|
||||
t.Errorf("%s: not refused saying %q: %v", name, says, err)
|
||||
}
|
||||
if n := len(readACLsOrNil(t, lib)); n > 3 {
|
||||
t.Errorf("%s: an entry was kept", name)
|
||||
}
|
||||
}
|
||||
// And only a tool runner's account may come without a number.
|
||||
if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.a","type":"access","path":"/srv/x",` +
|
||||
`"mode":"read-write","users":[{"identity":"mesh-m","use":"read","from":"container"}]}]}`)); err == nil {
|
||||
t.Error("a container's identity without a uid was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func readACLsOrNil(t *testing.T, path string) acl {
|
||||
a, _, _ := readACLs(t, path)
|
||||
return a
|
||||
}
|
||||
|
||||
// The take act names its access by the declared resource id exactly, `/` and all; anything not declared is refused.
|
||||
func TestTheTakeActMatchesTheDeclaredAccessId(t *testing.T) {
|
||||
dir := aclFolder(t)
|
||||
keeping(t, nil, nil)
|
||||
lib := filepath.Join(dir, "movies")
|
||||
if err := os.MkdirAll(filepath.Join(lib, "a film"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := "radarr.access-services/media/movies"
|
||||
a := `{"id":"` + id + `","type":"access","path":"` + lib + `","mode":"read-write","users":[` +
|
||||
`{"identity":"mesh-radarr","uid":61001,"use":"read-write","from":"container"}]}`
|
||||
toUser(t)
|
||||
if _, _, err := TakeFolder(context.Background(), declare(t, a), "radarr.access-services/media", lib, "w", "op",
|
||||
noServices); err == nil {
|
||||
t.Fatal("an access not declared was taken")
|
||||
}
|
||||
toUser(t)
|
||||
done, _, err := TakeFolder(context.Background(), declare(t, a), id, lib, "w", "op", noServices)
|
||||
if err != nil {
|
||||
t.Fatalf("the declared id with a slash was refused: %v", err)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
// The words a keep wait says of ACLs: only a ZFS dataset is one the take act lets carry them.
|
||||
func TestAKeepWaitSaysWhetherTheTakeActCanAllowACLs(t *testing.T) {
|
||||
if aclWord("tank/media", "zfs") != ACLNeedsEnabling || aclWord("/dev/sdb1", "ext4") != ACLUnsupported ||
|
||||
aclWord("", "zfs") != ACLUnsupported {
|
||||
t.Fatal("the ACL words are not what the take act can do")
|
||||
}
|
||||
dir := aclFolder(t) // carries ACLs: the wait says nothing of them
|
||||
keeping(t, nil, nil)
|
||||
lib := filepath.Join(dir, "music")
|
||||
if err := os.MkdirAll(filepath.Join(lib, "album"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toUser(t)
|
||||
if v := AccessVerdict(context.Background(), lib, false, true); v.Kind != VerdictWaitKeep || v.ACL != "" {
|
||||
t.Fatalf("a folder whose filesystem allows ACLs is said %+v", v)
|
||||
}
|
||||
}
|
||||
+64
-3
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-host/internal/accounts"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -476,6 +477,10 @@ func RootWords(why string) string {
|
||||
// Nothing is changed on the folder but the entries the mesh adds and the mask that shows them.
|
||||
func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied,
|
||||
in inputs) (Outcome, error) {
|
||||
// A tool runner's account that is not here, or is root's, refuses its access (§4, §11).
|
||||
if err := in.runners[r.ID]; err != nil {
|
||||
return begin(r), err
|
||||
}
|
||||
// What ADR 0312's grant put an account in is taken back first: no account is in a group for data (§5).
|
||||
gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed)
|
||||
out, err := applyAccessFolder(ctx, r, in)
|
||||
@@ -832,10 +837,18 @@ const (
|
||||
VerdictNobody = "nobody"
|
||||
)
|
||||
|
||||
// What a keep wait says of its folder's filesystem and ACLs: the same words as link's ACLNeedsEnabling and
|
||||
// ACLUnsupported, which the engine says them as.
|
||||
const (
|
||||
ACLNeedsEnabling = "needs-enabling"
|
||||
ACLUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// AccessJudgement is a look's judgement of an access's folder, and why, in words; Placed names the filesystem at
|
||||
// it for a wait, with no path (PlacedID).
|
||||
// it for a wait, with no path (PlacedID); ACL is, on a keep wait, ACLNeedsEnabling or ACLUnsupported
|
||||
// when its filesystem refuses access entries now.
|
||||
type AccessJudgement struct {
|
||||
Kind, Why, Placed string
|
||||
Kind, Why, Placed, ACL string
|
||||
}
|
||||
|
||||
// AccessVerdict judges an access's folder for a look, read only (ADR 0325 §7, §9, §12): taken is whether the
|
||||
@@ -858,7 +871,7 @@ func AccessVerdict(ctx context.Context, path string, taken, used bool) AccessJud
|
||||
case v.kind == folderMake:
|
||||
return AccessJudgement{Kind: VerdictMake}
|
||||
case v.kind == folderWaitKeep, v.kind == folderKeep && !rec.Kept && !taken:
|
||||
return AccessJudgement{Kind: VerdictWaitKeep, Placed: PlacedID(v.mount)}
|
||||
return AccessJudgement{Kind: VerdictWaitKeep, Placed: PlacedID(v.mount), ACL: aclProbe(path, v.mount)}
|
||||
}
|
||||
return AccessJudgement{Kind: VerdictKeep}
|
||||
}
|
||||
@@ -951,3 +964,51 @@ func legacyAccess(r *declaration.Access) (Outcome, error) {
|
||||
out.Detail = "operator-owned; present, " + mode + ", nothing managed"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountIDs reads an account's uid and primary gid by name from the machine's user database (getent, through the
|
||||
// account judge's reader). A variable so a test can stand in for accounts it cannot make.
|
||||
var AccountIDs = func(ctx context.Context, name string) (uid, gid int, found bool, err error) {
|
||||
return accounts.Exec{Run: accounts.Runner(ExecRunner)}.IDs(ctx, name)
|
||||
}
|
||||
|
||||
// ResolveRunners gives every tool runner's account that came with no uid its number, read by name on this machine
|
||||
// (ADR 0325 §4): the controller cannot know the operator's account's. One that is not here, or is root's — uid 0 or
|
||||
// a root group (§11) — is taken out of the access's users, and its access is refused, naming why, by id. Changes
|
||||
// the declaration in place; a user that already has its number is left as it is.
|
||||
func ResolveRunners(ctx context.Context, d *declaration.Declaration) map[string]error {
|
||||
refused := map[string]error{}
|
||||
if d == nil {
|
||||
return refused
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
a, ok := r.(*declaration.Access)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
kept := a.Users[:0]
|
||||
for _, u := range a.Users {
|
||||
if u.From != declaration.FromToolRunner || u.UID != 0 {
|
||||
kept = append(kept, u)
|
||||
continue
|
||||
}
|
||||
uid, gid, found, err := AccountIDs(ctx, u.Identity)
|
||||
switch {
|
||||
case err != nil:
|
||||
refused[a.ID] = fmt.Errorf("the tool runner's account %s could not be read: %w", u.Identity, err)
|
||||
continue
|
||||
case !found:
|
||||
refused[a.ID] = fmt.Errorf("the tool runner's account %s is not on this machine, so no entry can "+
|
||||
"be kept for it (novox/hq ADR 0325 §4)", u.Identity)
|
||||
continue
|
||||
}
|
||||
if err := rootIdentity(uid, gid); err != nil {
|
||||
refused[a.ID] = fmt.Errorf("the tool runner's account %s is never given an entry: %w", u.Identity, err)
|
||||
continue
|
||||
}
|
||||
u.UID = uid
|
||||
kept = append(kept, u)
|
||||
}
|
||||
a.Users = kept
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
@@ -242,6 +242,9 @@ func TakeFolder(ctx context.Context, d *declaration.Declaration, id, path, warra
|
||||
if d == nil {
|
||||
return nil, "", errors.New("this engine has applied no declaration, and takes no folder it was not told of")
|
||||
}
|
||||
if err := ResolveRunners(ctx, d)[id]; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var access *declaration.Access
|
||||
for _, r := range d.Resources {
|
||||
if a, ok := r.(*declaration.Access); ok && a.ID == id {
|
||||
@@ -444,3 +447,27 @@ func JudgeKept(path string, users []store.KeptUser, limits WalkLimits) (WalkCoun
|
||||
})
|
||||
return w.counts, err
|
||||
}
|
||||
|
||||
// aclProbe is what a keep wait says of its folder's filesystem and ACLs: ACLNeedsEnabling when it refuses them
|
||||
// now and is a ZFS dataset the take act lets carry them, ACLUnsupported when no act can, "" when it allows
|
||||
// them or the folder cannot be read (the wait says nothing then). Reads only.
|
||||
func aclProbe(path, mount string) string {
|
||||
_, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if _, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777); !errors.Is(err, ErrNoACL) {
|
||||
return ""
|
||||
}
|
||||
return aclWord(mountSourceOf(mount))
|
||||
}
|
||||
|
||||
// aclWord is what a keep wait says of a filesystem that refuses ACLs now, by its mount's source and type: only a
|
||||
// ZFS dataset is one the take act lets carry them.
|
||||
func aclWord(source, fstype string) string {
|
||||
if fstype == "zfs" && source != "" {
|
||||
return ACLNeedsEnabling
|
||||
}
|
||||
return ACLUnsupported
|
||||
}
|
||||
|
||||
@@ -59,3 +59,5 @@ var MountFsid = func(string) string { return "" }
|
||||
|
||||
// MountUUID reads no UUID where a folder is not kept.
|
||||
var MountUUID = func(string) string { return "" }
|
||||
|
||||
func aclProbe(string, string) string { return "" }
|
||||
|
||||
Reference in New Issue
Block a user