package main // approvals_owner.go — the one account whose tray the approvals pipe serves. // // The pipe (approvalpipe.go) admits a process only when it runs as the person // signed in to its own active session. On a machine with more than one // account, that is any of them. The approvals belong to the person who set the // bastion up for their Host, so the service records that account's SID in its // own state, and the pipe refuses every other SID for list, deny and approve. // // WHO WRITES IT. An installer's elevated step (`npx cseries`), with machine // authority, into the system service's state, which a process running as the // person can neither write nor read: `approvals-owner --sid `. The SID is // the account that ran the installer, which the elevated step may not be (it // may run as another administrator), so it is passed in. // // A RERUN FROM ANOTHER ACCOUNT can neither change the owner silently nor lock // the first owner out. The same SID again changes nothing. A different SID is // refused, and the recorded owner stays exactly as it was, unless --replace is // given. --replace is taken only on a console, so it is a person at an // Administrator terminal handing the approvals over on purpose: an installer // runs its commands with their output captured, so it can never pass it. A // record that exists but cannot be read counts as an owner for this rule, so // a damaged file is never quietly replaced; the pipe serves nobody until an // administrator rewrites it with --replace. Refusing here costs the second // account a message naming the command; taking the record over would hand the // first person's approvals to someone else, and clearing it would lock them out. import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "runtime" "strings" "time" ) const approvalsOwnerFile = "approvals_owner.json" // The record names the owner one way per platform: a SID on Windows, a uid // on Linux and macOS, where the account is the one the console lane serves // (consolelane.go). Account is that uid's name when it was recorded, for // messages only; the uid is what is compared, since a name can be changed. type approvalsOwnerRecord struct { SID string `json:"sid,omitempty"` UID *uint32 `json:"uid,omitempty"` Account string `json:"account,omitempty"` SetAt string `json:"set_at"` } var winSIDShape = regexp.MustCompile(`^S-1-[0-9]+(-[0-9]+)+$`) // approvalsOwnerSIDOK: a well-formed SID of a person's account. func approvalsOwnerSIDOK(sid string) bool { return winSIDShape.MatchString(sid) && winPersonSID(sid) } func approvalsOwnerPath() (string, error) { d, err := configDirPath() if err != nil { return "", err } return filepath.Join(d, approvalsOwnerFile), nil } // ownerRecordLoad reads the record as it is. present is true when a record // exists, readable or not. func ownerRecordLoad() (r approvalsOwnerRecord, present bool, err error) { p, err := approvalsOwnerPath() if err != nil { return r, false, err } raw, err := os.ReadFile(p) if errors.Is(err, fs.ErrNotExist) { return r, false, nil } if err != nil { return r, true, err } if err := json.Unmarshal(raw, &r); err != nil { return approvalsOwnerRecord{}, true, fmt.Errorf("%s cannot be read: %w", p, err) } return r, true, nil } // approvalsOwnerLoad reads the record. present is true when a record exists, // readable or not; sid is "" unless it is readable and names a person. func approvalsOwnerLoad() (sid string, present bool, err error) { r, present, err := ownerRecordLoad() if err != nil || !present { return "", present, err } if !approvalsOwnerSIDOK(r.SID) { p, _ := approvalsOwnerPath() return "", true, fmt.Errorf("%s names no person's account", p) } return r.SID, true, nil } // ownerAccountLoad is the Linux and macOS reading of the record: the owner's // uid, never root's (root needs no lane to its own bastion). func ownerAccountLoad() (uid uint32, account string, present bool, err error) { r, present, err := ownerRecordLoad() if err != nil || !present { return 0, "", present, err } if r.UID == nil || *r.UID == 0 { p, _ := approvalsOwnerPath() return 0, "", true, fmt.Errorf("%s names no person's account on this system", p) } return *r.UID, r.Account, true, nil } // approvalsOwner is the SID the pipe serves, "" when none is recorded or the // record cannot be read: the pipe then serves nobody. func approvalsOwner() string { sid, _, err := approvalsOwnerLoad() if err != nil { return "" } return sid } // pipeOwnerRefusal says why client c is not the account the lane serves, or "" // when it is. func pipeOwnerRefusal(c pipeClient, owner string) string { switch { case owner == "": return "no account is recorded as owning this machine's approvals; from an Administrator terminal: " + "privatae-bastion approvals-owner --sid " case !strings.EqualFold(owner, c.UserSID): return "this machine's approvals belong to another account, not " + pipeWho(c) } return "" } // ownerConsole and ownerServiceState are seams so the suite drives the // command on any machine; nothing outside tests assigns them. var ( ownerConsole = stdioIsConsole ownerServiceState = func() string { return enrolServiceStateRefusal(runtime.GOOS, systemService.installed, systemService.authority, systemService.err) } ) // privatae-bastion approvals-owner --sid [--replace] func cmdApprovalsOwner(sid string, replace bool) error { if err := requireMachineAuthority("approvals-owner"); err != nil { return err } if why := ownerServiceState(); why != "" { return fmt.Errorf("approvals-owner records the owner in the system service's state: %s — nothing was changed", why) } if !approvalsOwnerSIDOK(sid) { return fmt.Errorf("%q is not the SID of a person's account (S-1-5-21-… or S-1-12-1-…) — nothing was changed", sid) } if replace && !ownerConsole() { return errors.New("approvals-owner --replace hands this machine's approvals to another account, and is taken only " + "in an Administrator terminal; this one's input or output goes to a file or a pipe — nothing was changed") } cur, present, err := approvalsOwnerLoad() switch { case err == nil && strings.EqualFold(cur, sid): fmt.Printf("approvals on this machine already belong to %s\n", sid) return nil case present && !replace: was := cur if err != nil { was = "an account its record no longer names readably (" + err.Error() + ")" } return fmt.Errorf("this machine's approvals belong to %s, and stay with it — nothing was changed.\n"+ " To hand them to %s, run from an Administrator terminal: privatae-bastion approvals-owner --sid %s --replace", was, sid, sid) } p, err := approvalsOwnerPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { return err } raw, err := json.MarshalIndent(approvalsOwnerRecord{SID: sid, SetAt: time.Now().UTC().Format(time.RFC3339)}, "", " ") if err != nil { return err } if err := atomicWrite(p, raw); err != nil { return err } fmt.Printf("approvals on this machine belong to %s; the tray serves that account only\n", sid) return nil }