diff --git a/cmd/mesh-host/kept.go b/cmd/mesh-host/kept.go index d435a77..0e28c3c 100644 --- a/cmd/mesh-host/kept.go +++ b/cmd/mesh-host/kept.go @@ -60,6 +60,10 @@ func newAccessJudge() *accessJudge { func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) { var as []judgedAccess if d != nil { + // A tool runner's account that came with no number is read by name, as the apply read it (ADR 0325 §4). + ctx, cancel := context.WithTimeout(context.Background(), lookBound) + apply.ResolveRunners(ctx, d) + cancel() users := map[string]map[uint32]store.KeptUser{} taken := map[string]bool{} for _, r := range d.Resources { @@ -161,11 +165,11 @@ func judgeAccess(a judgedAccess) verdict { return verdict{state: link.StateWaiting, reason: "it holds data the mesh has not taken on the filesystem " + "it is on now, and waits for the operator's word (novox/hq ADR 0325 §7, §8)", waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module, - What: apply.KeepWords(a.name), Placed: v.Placed}}} + What: apply.KeepWords(a.name), Placed: v.Placed, Reason: link.WaitKeep, ACL: v.ACL}}} case apply.VerdictWaitDisk: return verdict{state: link.StateWaiting, reason: "the disk that holds it is not there (novox/hq ADR 0325 §9)", waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module, - What: apply.DiskWords(a.name)}}} + What: apply.DiskWords(a.name), Reason: link.WaitDisk}}} case apply.VerdictRoot: return verdict{state: link.StateUnhealthy, reason: apply.RootWords(v.Why)} case apply.VerdictUnknown: @@ -288,6 +292,10 @@ func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) { out := make([]link.ResourceHealth, 0, len(as)) for i, a := range as { r := read[i] + // Every kept or waiting access lists its users, the tool runner's account with the uid read here (§4). + if r.entries == nil && len(a.users) > 0 && !a.leftover { + r.entries = entriesOf(a.users) + } kind := link.KindAccess if a.leftover { kind = link.KindKeptEntries diff --git a/cmd/mesh-host/kept_test.go b/cmd/mesh-host/kept_test.go index 209ce37..36e267e 100644 --- a/cmd/mesh-host/kept_test.go +++ b/cmd/mesh-host/kept_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "testing" "time" + "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" @@ -48,7 +50,7 @@ func TestAnAccessTheMeshMayNotKeepYetIsSaidAsAWaitOfItsOwnKind(t *testing.T) { } raw, _ := json.Marshal(v.waits[0]) want := `{"kind":"access","access":"movies","part":"radarr","what":"let the mesh keep the shared folder movies",` + - `"placed":"` + v.waits[0].Placed + `"}` + `"placed":"` + v.waits[0].Placed + `","reason":"keep"}` if string(raw) != want || !strings.HasPrefix(v.waits[0].Placed, "fs-") { t.Fatalf("the wait reads %s", raw) } @@ -76,3 +78,40 @@ func TestAnOlderControllersAccessesSayNothing(t *testing.T) { t.Fatalf("an older controller's accesses are judged: %+v", vs) } } + +// The waits' reasons and the tool runner's uid read here are said without words to parse (ADR 0325 §4, §7, §9). +func TestTheWaitsSayTheirReasonAndTheRunnersUid(t *testing.T) { + if link.ACLNeedsEnabling != apply.ACLNeedsEnabling || link.ACLUnsupported != apply.ACLUnsupported { + t.Fatal("the engine's ACL words are not the link's") + } + dir := t.TempDir() + was := apply.AccountIDs + apply.AccountIDs = func(context.Context, string) (int, int, bool, error) { return 61003, 1000, true, nil } + defer func() { apply.AccountIDs = was }() + d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"importer.books","type":"access",` + + `"path":"` + filepath.Join(dir, "no", "such") + `","mode":"read-write","users":[{"identity":"nox",` + + `"use":"read-write","from":"tool-runner"}]}]}`)) + if err != nil { + t.Fatal(err) + } + j := newAccessJudge() + j.set(d, nil) + vs, _ := j.look(time.Now()) + if len(vs) != 1 || len(vs[0].Entries) != 1 || vs[0].Entries[0].UID != 61003 { + t.Fatalf("the tool runner's uid read here is not said: %+v", vs) + } + // A folder whose disk is not there: its mount target named, not mounted. + disk := filepath.Join(dir, "disk") + if err := os.Mkdir(disk, 0o755); err != nil { + t.Fatal(err) + } + wasTargets := apply.MountTargets + apply.MountTargets = func(context.Context) []string { return []string{disk} } + defer func() { apply.MountTargets = wasTargets }() + v := judgeAccess(judgedAccess{module: "radarr", id: "radarr.movies", name: "movies", + path: filepath.Join(disk, "movies"), users: []store.KeptUser{{Identity: "mesh-radarr", UID: 61001, + Use: "read-write", From: "container"}}}) + if len(v.waits) != 1 || v.waits[0].Reason != link.WaitDisk || link.WaitDisk != "disk" || link.WaitKeep != "keep" { + t.Fatalf("a disk wait does not say its reason: %+v", v.waits) + } +} diff --git a/internal/accounts/exec.go b/internal/accounts/exec.go index 55a3eb4..277f6fa 100644 --- a/internal/accounts/exec.go +++ b/internal/accounts/exec.go @@ -285,3 +285,26 @@ func (e Exec) heldBy(pid string) (map[string]bool, error) { } return held, nil } + +// IDs is `getent passwd`: an account's uid and primary gid, and whether the user database names it at all. A +// database that does not answer is an error, never "no such account". +func (e Exec) IDs(ctx context.Context, account string) (uid, gid int, found bool, err error) { + out, err := e.Run(ctx, "getent", "passwd", account) + if err != nil { + if strings.Contains(err.Error(), "exited 2") || strings.Contains(err.Error(), "exit status 2") { + return 0, 0, false, nil + } + return 0, 0, false, fmt.Errorf("the user database did not answer about %q: %w", account, err) + } + fields := strings.Split(strings.TrimSpace(out), ":") + if len(fields) < 4 { + return 0, 0, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry", + strings.TrimSpace(out), account) + } + uid, uerr := strconv.Atoi(fields[2]) + gid, gerr := strconv.Atoi(fields[3]) + if uerr != nil || gerr != nil { + return 0, 0, false, fmt.Errorf("the user database gave no numbers for %q", account) + } + return uid, gid, true, nil +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 0934dcd..ae74865 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -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 } diff --git a/internal/apply/contract_test.go b/internal/apply/contract_test.go new file mode 100644 index 0000000..6532c8c --- /dev/null +++ b/internal/apply/contract_test.go @@ -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) + } +} diff --git a/internal/apply/keep.go b/internal/apply/keep.go index 27d5afc..0b7ee43 100644 --- a/internal/apply/keep.go +++ b/internal/apply/keep.go @@ -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 +} diff --git a/internal/apply/keeper_linux.go b/internal/apply/keeper_linux.go index a4f6afc..c3fd1d2 100644 --- a/internal/apply/keeper_linux.go +++ b/internal/apply/keeper_linux.go @@ -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 +} diff --git a/internal/apply/walk_other.go b/internal/apply/walk_other.go index 7d9c79a..937c670 100644 --- a/internal/apply/walk_other.go +++ b/internal/apply/walk_other.go @@ -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 "" } diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 4f4ba92..a8b89cd 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -489,10 +489,10 @@ const ( ) // AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its -// use, and where it runs. +// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name. type AccessUser struct { Identity string `json:"identity"` - UID int `json:"uid"` + UID int `json:"uid,omitempty"` Use string `json:"use"` From string `json:"from"` } @@ -529,9 +529,11 @@ func (a *Access) validate(where string, _ bool) []string { case u.Identity == "" || !accountName.MatchString(u.Identity): problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one", where, u.Identity)) - case u.UID <= 0: + case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner): // Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a - // number below it is no account. + // number below it is no account. A tool runner's account alone may come without its number: the + // controller cannot know the operator's account's, and the engine reads it by name + // (apply.ResolveRunners), refusing one that is root's. problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+ "root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID)) } diff --git a/internal/link/access_act_test.go b/internal/link/access_act_test.go index 3d745b2..0bd38f6 100644 --- a/internal/link/access_act_test.go +++ b/internal/link/access_act_test.go @@ -47,11 +47,14 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { Nonce: nonce} } for why, body := range map[string][]byte{ - "no warrant": signedAct(t, key, ask(aNonce+"01"), AccessActAsk{Act: ActTake, Access: "radarr.movies"}), - "another act": signedAct(t, key, ask(aNonce+"02"), AccessActAsk{Act: "share", Access: "radarr.movies", Warrant: "w"}), - "no access": signedAct(t, key, ask(aNonce+"03"), AccessActAsk{Act: ActTake, Warrant: "w"}), - "a hand-over's": signedAsk(t, key, ask(aNonce+"04")), - "a path as access": signedAct(t, key, ask(aNonce+"05"), AccessActAsk{Act: ActTake, Access: "/srv", Warrant: "w"}), + "no warrant": signedAct(t, key, ask(aNonce+"01"), AccessActAsk{Act: ActTake, Access: "radarr.movies"}), + "another act": signedAct(t, key, ask(aNonce+"02"), AccessActAsk{Act: "share", Access: "radarr.movies", Warrant: "w"}), + "no access": signedAct(t, key, ask(aNonce+"03"), AccessActAsk{Act: ActTake, Warrant: "w"}), + "a hand-over's": signedAsk(t, key, ask(aNonce+"04")), + "a slash in the warrant": signedAct(t, key, ask(aNonce+"05"), AccessActAsk{Act: ActTake, Access: "radarr.movies", + Warrant: "w/1"}), + "two lines as access": signedAct(t, key, ask(aNonce+"07"), AccessActAsk{Act: ActTake, Access: "radarr.\nmovies", + Warrant: "w"}), } { if a := AnswerAccessAct(check, handle, body); a.Refused == "" || !strings.HasSuffix(a.Refused, "nothing was changed") { t.Errorf("%s: not refused, or refused without saying nothing was changed: %+v", why, a) @@ -66,3 +69,19 @@ func TestTheTakeActRunsOnlyOnAWarrant(t *testing.T) { t.Fatalf("a warranted ask did not reach the act whole: %+v %+v", a, given) } } + +// The controller names an access by its declared resource id exactly, which may hold a `/`: it reaches the act, +// which matches it against what was declared. +func TestATakeActNamesItsAccessByItsDeclaredId(t *testing.T) { + m, key := aMember(t) + var given []AccessAct + handle := func(a AccessAct) (string, error) { given = append(given, a); return "started", nil } + check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}} + ask := HandOverAsk{Node: m.Node, Path: "/services/media/movies", By: "the operator", + Expires: time.Now().Add(HandOverGood), Nonce: aNonce + "08"} + id := "radarr.access-services/media/movies" + a := AnswerAccessAct(check, handle, signedAct(t, key, ask, AccessActAsk{Act: ActTake, Access: id, Warrant: "w-1"})) + if a.Refused != "" || len(given) != 1 || given[0].Access != id { + t.Fatalf("an access id with a slash did not reach the act as declared: %+v %+v", a, given) + } +} diff --git a/internal/link/handover.go b/internal/link/handover.go index 69eec73..dc2169e 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -355,7 +355,8 @@ const ( ) // AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the -// access by its resource id, and the warrant the operator approved. +// access by its declared resource id exactly as the declaration names it (`/` allowed), and the warrant the +// operator approved. type AccessActAsk struct { Act string `json:"act"` Access string `json:"access"` @@ -400,10 +401,14 @@ func AnswerAccessAct(check HandOverCheck, handle AccessActFunc, body []byte) Han case act.Act != ActTake: return refusedAct(fmt.Sprintf("the ask names the act %q, and the one act asked is %q", act.Act, ActTake)) } - for _, v := range []string{act.Access, act.Warrant} { - if len(v) > MaxActField || strings.ContainsAny(v, "\r\n\x00/") { - return refusedAct("the ask's access or warrant is not one short name") - } + // The access is the declared resource's id exactly as the declaration names it — which may hold a `/` + // (radarr.access-services/media/movies) — and the engine matches it against what it was declared, refusing + // anything else; the warrant is one short name. + if len(act.Access) > MaxActField || strings.ContainsAny(act.Access, "\r\n\x00") { + return refusedAct("the ask's access is not one short line") + } + if len(act.Warrant) > MaxActField || strings.ContainsAny(act.Warrant, "\r\n\x00/") { + return refusedAct("the ask's warrant is not one short name") } said, err := handle(AccessAct{Act: act.Act, Access: act.Access, Path: ask.Path, Warrant: act.Warrant, By: ask.By}) if err != nil { diff --git a/internal/link/messages.go b/internal/link/messages.go index ab77a04..b878a6b 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -453,8 +453,23 @@ type AccessWait struct { // Placed names, with no path, the filesystem the folder is on now, on a wait for the operator's word: a warrant // given for another filesystem (the folder moved) does not answer it, and the controller asks again. Placed string `json:"placed,omitempty"` + // Reason is what the wait is for, for the controller to read without parsing words: WaitKeep (the operator's + // word to keep it) or WaitDisk (its disk mounted). + Reason string `json:"reason"` + // ACL is said on a keep wait whose folder's filesystem refuses access entries now: ACLNeedsEnabling when the + // take act will let it (a ZFS dataset's acltype, which the ask then says it sets), ACLUnsupported when no act + // can (the operator mounts it with ACLs). Absent when it allows them. + ACL string `json:"acl,omitempty"` } +// The reasons of an access wait, and what its filesystem says of ACLs. +const ( + WaitKeep = "keep" + WaitDisk = "disk" + ACLNeedsEnabling = "needs-enabling" + ACLUnsupported = "unsupported" +) + // KeptEntry is one user of a kept folder as its listing says it. type KeptEntry struct { Identity string `json:"identity"`