[client] Profile ownership console user tofu (#7529)

* Add consoleuser and stamp default profile on known username in migration

* Refactor consoleuser to verify Id, fix seats on linux and default stamp

* Add default profile claim

* Add disable auto-claim of default profile and always fail close

* Add disable auto-claim flag to migration

* Adding timeout to console user on Linux and close library load on darwin

* Fixed failed close test

* Close both Dlopen for darwin

* Replace RegisterFunc with purego.Dlsym to avoid possible panic

* Fix freebsd tty enumeration

* Fix active profile migration logic and add test

* Log defaultClaimDisabled error once

* Guard against panicking console user lookup.

* Fix merge conflict

* Fix broken tests
This commit is contained in:
Theodor Midtlien
2026-09-17 11:20:13 +02:00
committed by GitHub
parent 52b16e7a5c
commit 90052cbefb
12 changed files with 712 additions and 13 deletions
+43
View File
@@ -0,0 +1,43 @@
package ipcauth
import (
"sync"
log "github.com/sirupsen/logrus"
)
// logConsolePanic keeps the notice to once per process.
var logConsolePanic sync.Once
// IsConsoleUser reports whether a caller is sitting at one of this machine's
// consoles right now.
//
// It is false on a headless machine, which has no seat to sit at, on a
// platform that exposes no console-user lookup at all, and whenever the lookup
// fails. Callers must read that as "cannot confirm" rather than as proof of
// absence. It gates handing out ownership, so a lookup that cannot answer
// withholds a claim, and never grants one.
func IsConsoleUser(id Identity) bool {
if !id.Known() {
return false
}
return guardConsoleLookup(id, isConsoleUser)
}
// guardConsoleLookup runs a platform lookup and turns a panic out of it into
// "cannot confirm".
func guardConsoleLookup(id Identity, lookup func(Identity) bool) (atConsole bool) {
defer func() {
r := recover()
if r == nil {
return
}
atConsole = false
logConsolePanic.Do(func() {
log.Errorf("console user lookup panicked, no caller will be treated as being at the console: %v", r)
})
}()
return lookup(id)
}
@@ -0,0 +1,95 @@
package ipcauth
import (
"unsafe"
"github.com/ebitengine/purego"
)
// isConsoleUser reports whether id is the user currently logged into the macOS
// GUI console session. Uses SCDynamicStoreCopyConsoleUser from the
// SystemConfiguration framework via purego (no cgo).
func isConsoleUser(id Identity) bool {
// A SID belongs to a Windows principal and has no uid to compare.
if id.IsWindows() {
return false
}
uid, ok := consoleUID()
return ok && uid == id.UID
}
// consoleUID returns the uid of the GUI console session, and false when nobody
// is logged in at it.
func consoleUID() (uint32, bool) {
sc, err := purego.Dlopen(
"/System/Library/Frameworks/SystemConfiguration.framework/SystemConfiguration",
purego.RTLD_NOW|purego.RTLD_GLOBAL,
)
if err != nil {
return 0, false
}
defer func() {
_ = purego.Dlclose(sc)
}()
cf, err := purego.Dlopen(
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation",
purego.RTLD_NOW|purego.RTLD_GLOBAL,
)
if err != nil {
return 0, false
}
defer func() {
_ = purego.Dlclose(cf)
}()
// CFStringRef SCDynamicStoreCopyConsoleUser(SCDynamicStoreRef store,
// uid_t *uid, gid_t *gid);
//
// We pass nil for the store (NULL is accepted; the framework creates a
// transient one), discard the returned CFStringRef username (we only
// need the UID), and read uid via the out-pointer.
copyConsoleUserSym, ok := resolveSymbol(sc, "SCDynamicStoreCopyConsoleUser")
if !ok {
return 0, false
}
cfReleaseSym, ok := resolveSymbol(cf, "CFRelease")
if !ok {
return 0, false
}
var copyConsoleUser func(store uintptr, uidPtr, gidPtr unsafe.Pointer) uintptr
purego.RegisterFunc(&copyConsoleUser, copyConsoleUserSym)
var cfRelease func(uintptr)
purego.RegisterFunc(&cfRelease, cfReleaseSym)
var uid uint32
var gid uint32
cfStr := copyConsoleUser(0, unsafe.Pointer(&uid), unsafe.Pointer(&gid))
if cfStr == 0 {
return 0, false
}
cfRelease(cfStr)
// loginwindow / no GUI session reports uid 0. We don't want the
// console-user path to grant anything to root, so treat uid 0 as "no
// console user".
if uid == 0 {
return 0, false
}
return uid, true
}
// resolveSymbol looks up one symbol and reports false when it is not there,
// rather than passing it to RegisterFunc which would panic.
func resolveSymbol(handle uintptr, name string) (uintptr, bool) {
sym, err := purego.Dlsym(handle, name)
if err != nil || sym == 0 {
return 0, false
}
return sym, true
}
@@ -0,0 +1,64 @@
package ipcauth
import (
"os"
"path/filepath"
"strings"
"syscall"
)
const (
devDir = "/dev"
// vtPrefix names the virtual terminals vt(4) publishes. How many there are
// is a kernel constant rather than a fixed number, and past the tenth they
// are not spelled in decimal: the unit is rendered in base 32, so the one
// after ttyv9 is ttyva.
vtPrefix = "ttyv"
)
// isConsoleUser reports whether id is logged into the FreeBSD console.
// FreeBSD's vt(4) chowns the virtual terminal device to the user logged in on
// it, so a non-root owner of any /dev/ttyv* reliably identifies a console user.
//
// Network ptys (pts) are intentionally not considered: SSH'd users are not "at
// the console".
func isConsoleUser(id Identity) bool {
// A SID belongs to a Windows principal and has no uid to compare.
if id.IsWindows() {
return false
}
// A root-owned ttyv is an unclaimed terminal rather than a root login, so
// uid 0 never matches. A root caller is privileged anyway and does not
// reach ownership checks through here.
if id.UID == 0 {
return false
}
entries, err := os.ReadDir(devDir)
if err != nil {
return false
}
for _, entry := range entries {
name := entry.Name()
suffix, ok := strings.CutPrefix(name, vtPrefix)
if !ok || suffix == "" {
continue
}
fi, err := os.Stat(filepath.Join(devDir, name))
if err != nil {
continue
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
continue
}
if st.Uid == id.UID {
return true
}
}
return false
}
@@ -0,0 +1,173 @@
package ipcauth
import (
"context"
"fmt"
"strings"
"time"
"github.com/godbus/dbus/v5"
log "github.com/sirupsen/logrus"
)
const (
loginDest = "org.freedesktop.login1"
loginPath = dbus.ObjectPath("/org/freedesktop/login1")
loginInterface = "org.freedesktop.login1.Manager"
listSeats = loginInterface + ".ListSeats"
seatInterface = "org.freedesktop.login1.Seat"
seatActiveSession = seatInterface + ".ActiveSession"
sessionInterface = "org.freedesktop.login1.Session"
sessionActive = sessionInterface + ".Active"
sessionClass = sessionInterface + ".Class"
sessionRemote = sessionInterface + ".Remote"
sessionUser = sessionInterface + ".User"
// propertiesGet is the standard property reader. godbus offers no
// context-aware GetProperty, and GetProperty is only this call underneath,
// so a bounded read has to make it directly.
propertiesGet = "org.freedesktop.DBus.Properties.Get"
// nullObjectPath is what logind puts in an object path field that refers to
// nothing, a seat with no session in the foreground being the one that
// matters here.
nullObjectPath = dbus.ObjectPath("/")
// consoleLookupTimeout bounds the whole seat walk, not each call in it, so
// a machine with several seats cannot multiply what an unanswering bus
// costs. A healthy lookup is well under a millisecond.
consoleLookupTimeout = 1 * time.Second
)
// isConsoleUser reports whether id holds the foreground session of one of this
// machine's seats.
//
// No seats means no console, which is the honest answer for a headless machine
// and the one that keeps its profiles to the privileged caller.
func isConsoleUser(id Identity) bool {
// A SID belongs to a Windows principal and has no uid to compare. Nothing
// on Linux produces one, and comparing anyway would match uid 0.
if id.IsWindows() {
return false
}
conn, err := dbus.SystemBus()
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), consoleLookupTimeout)
defer cancel()
// ListSeats returns a(so): seat id and object path.
var seats []struct {
ID string
Path dbus.ObjectPath
}
if err := conn.Object(loginDest, loginPath).CallWithContext(ctx, listSeats, 0).Store(&seats); err != nil {
log.Debugf("cannot list seats, treating the caller as not at the console: %v", err)
return false
}
for _, seat := range seats {
uid, ok := consoleSessionUID(ctx, conn, seat.Path)
if ok && uid == id.UID {
return true
}
}
return false
}
// consoleSessionUID returns who holds a seat's foreground session, and false
// unless that session is a person logged in locally.
func consoleSessionUID(ctx context.Context, conn *dbus.Conn, seatPath dbus.ObjectPath) (uint32, bool) {
// ActiveSession is (so): session id and object path. It names nothing on a
// seat whose VT has been switched away from, and on one whose display
// manager has not started a session yet.
prop, err := property(ctx, conn.Object(loginDest, seatPath), seatActiveSession)
if err != nil {
return 0, false
}
var active struct {
ID string
Path dbus.ObjectPath
}
if err := prop.Store(&active); err != nil {
return 0, false
}
if active.ID == "" || active.Path == "" || active.Path == nullObjectPath {
return 0, false
}
session := conn.Object(loginDest, active.Path)
// Only "user" sessions count: a greeter or a lock screen is the display
// manager sitting at the seat, not somebody to hand a profile to.
if class, ok := stringProperty(ctx, session, sessionClass); !ok || class != "user" {
return 0, false
}
// A remote session can hold a seat, and unlike the session's class and
// type, which pam_systemd takes from the environment of whoever opened the
// session, remoteness is set by the thing that accepted the connection. So
// it is worth asking even once the seat is established.
if remote, ok := boolProperty(ctx, session, sessionRemote); !ok || remote {
return 0, false
}
// Implied by the seat having named this session, and kept as a cross-check
// against a foreground that moved between the two calls.
if isActive, ok := boolProperty(ctx, session, sessionActive); !ok || !isActive {
return 0, false
}
// User is (uo): uid and the user object's path. Read from the session
// object rather than carried over from a listing, so the uid returned and
// the checks above are known to describe the same session.
prop, err = property(ctx, session, sessionUser)
if err != nil {
return 0, false
}
var owner struct {
UID uint32
Path dbus.ObjectPath
}
if err := prop.Store(&owner); err != nil {
return 0, false
}
return owner.UID, true
}
// property reads one property under ctx, name being the interface.member form
// godbus takes. It is what GetProperty does, with a deadline the caller owns.
func property(ctx context.Context, obj dbus.BusObject, name string) (dbus.Variant, error) {
idx := strings.LastIndex(name, ".")
if idx < 0 || idx+1 == len(name) {
return dbus.Variant{}, fmt.Errorf("invalid property name %q", name)
}
var v dbus.Variant
err := obj.CallWithContext(ctx, propertiesGet, 0, name[:idx], name[idx+1:]).Store(&v)
return v, err
}
func stringProperty(ctx context.Context, obj dbus.BusObject, name string) (string, bool) {
prop, err := property(ctx, obj, name)
if err != nil {
return "", false
}
s, ok := prop.Value().(string)
return s, ok
}
func boolProperty(ctx context.Context, obj dbus.BusObject, name string) (bool, bool) {
prop, err := property(ctx, obj, name)
if err != nil {
return false, false
}
b, ok := prop.Value().(bool)
return b, ok
}
@@ -0,0 +1,13 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
// isConsoleUser has no meaning on a platform that exposes no console-user
// lookup, where nobody is ever at a console.
//
// Mobile is not built from here: ios satisfies darwin and android satisfies
// linux, so both take those lookups, which are present but never find a GUI
// session or a seat.
func isConsoleUser(Identity) bool {
return false
}
@@ -0,0 +1,54 @@
package ipcauth
import "testing"
// An identity the kernel never vouched for must not reach a console lookup at
// all: the zero Identity carries uid 0, which a platform lookup would
// otherwise compare against a root session.
func TestIsConsoleUserRejectsUnattestedIdentity(t *testing.T) {
for _, tc := range []struct {
name string
id Identity
}{
{"zero identity", Identity{}},
{"unattested uid 0", Identity{UID: 0}},
{"unattested uid", Identity{UID: 1000}},
{"unattested sid", Identity{SID: "S-1-5-21-1-2-3-1001"}},
} {
t.Run(tc.name, func(t *testing.T) {
if IsConsoleUser(tc.id) {
t.Fatal("an identity the kernel did not vouch for was reported as a console user")
}
})
}
}
// A lookup that panics must read as "cannot confirm". The daemon installs no
// gRPC recovery interceptor, so without this the panic ends the process from
// inside the per-RPC authorization path.
func TestGuardConsoleLookupContainsAPanic(t *testing.T) {
id := KnownForTest(Identity{UID: 1000})
if guardConsoleLookup(id, func(Identity) bool {
panic("pretending purego could not map a signature")
}) {
t.Fatal("a panicking lookup reported the caller as being at the console")
}
}
// The guard must not swallow a real answer on its way out.
func TestGuardConsoleLookupPassesTheAnswerThrough(t *testing.T) {
id := KnownForTest(Identity{UID: 1000})
if !guardConsoleLookup(id, func(got Identity) bool {
if got.UID != id.UID {
t.Fatalf("lookup received uid %d, want %d", got.UID, id.UID)
}
return true
}) {
t.Fatal("a lookup that found the caller at the console reported false")
}
if guardConsoleLookup(id, func(Identity) bool { return false }) {
t.Fatal("a lookup that found nobody reported true")
}
}
@@ -0,0 +1,40 @@
package ipcauth
import (
"golang.org/x/sys/windows"
)
// isConsoleUser reports whether id is the user logged into the active Windows
// console session.
//
// Returns false when there is no active console session, the session has no
// logged-in user, or any lookup fails.
func isConsoleUser(id Identity) bool {
// A caller with no SID is not a Windows principal and has nothing to
// compare against the console session's token.
if !id.IsWindows() {
return false
}
sessionID := windows.WTSGetActiveConsoleSessionId()
if sessionID == 0xFFFFFFFF {
return false
}
var token windows.Token
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
return false
}
defer token.Close()
console, err := identityFromToken(token)
if err != nil {
return false
}
// The console session's token and the caller's token carry the same SID for
// the same account, so the SID is what the two identities share. Elevation
// is deliberately not compared: whether the caller's shell is elevated says
// nothing about who is sitting at the console.
return console.SID != "" && console.SID == id.SID
}
+21 -3
View File
@@ -177,7 +177,8 @@ func undoMoves(moved []movedFile) {
}
// stampActiveUserDir records the owner of every unowned profile in the
// directory of the account the active profile state names.
// directory of the account the active profile state names and the default
// profile.
//
// That name is the one lossless input the old layout left behind. Resolving it
// forward, from name to uid, avoids reversing a sanitized directory name, which
@@ -198,21 +199,38 @@ func (s *ServiceManager) stampActiveUserDir(profiles []Profile, active *ActivePr
}
dir := sanitizeProfileName(active.Username)
if dir == "" {
log.Warnf("account %q leaves nothing after sanitizing, so its per-username profiles stay unowned", active.Username)
}
for i := range profiles {
p := &profiles[i]
if len(p.Owners) > 0 || p.LegacyUserDir != dir {
if len(p.Owners) > 0 || !takesActiveAccountOwner(p, dir) {
continue
}
if err := stampPrincipal(p.Path, principal); err != nil {
log.Warnf("leaving %s unowned, its owner could not be recorded: %v", p.Path, err)
continue
}
log.Infof("recorded %s as the owner of %s, the directory it sits in is that account's", principal, p.Path)
log.Infof("recorded %s as the owner of %s, the account the active profile state names", principal, p.Path)
}
return nil
}
// takesActiveAccountOwner reports whether an unowned profile should be stamped
// with the active account's principal, dir being the legacy directory name that
// account produced.
//
// An empty dir is the absence of a directory, not a directory whose name is
// empty, so nothing matches it.
func takesActiveAccountOwner(p *Profile, dir string) bool {
if dir != "" && p.LegacyUserDir == dir {
return true
}
return p.ID == defaultProfileName && !defaultProfileClaimDisabled()
}
// principalForUser turns a resolved account into an owner principal. os/user
// reports a numeric id on Unix and a SID on Windows, which is what tells the
// two kinds apart without a build tag.
@@ -289,3 +289,73 @@ func TestMigrate_SkipsAProfileItCannotStamp(t *testing.T) {
assert.Equal(t, "null", string(data), "the profile it could not stamp is untouched")
})
}
// TestTakesActiveAccountOwner pins which profiles migration hands the active
// account's principal to.
func TestTakesActiveAccountOwner(t *testing.T) {
for _, tc := range []struct {
name string
dir string
profile Profile
disabled bool
want bool
}{
{
name: "profile in the account's own directory",
dir: "alice",
profile: Profile{ID: "work", LegacyUserDir: "alice"},
want: true,
},
{
name: "profile in another account's directory",
dir: "alice",
profile: Profile{ID: "work", LegacyUserDir: "bob"},
want: false,
},
{
name: "default profile",
dir: "alice",
profile: Profile{ID: defaultProfileName},
want: true,
},
{
name: "default profile with the claim disabled",
dir: "alice",
profile: Profile{ID: defaultProfileName},
disabled: true,
want: false,
},
{
name: "default profile when the account has no directory name",
dir: "",
profile: Profile{ID: defaultProfileName},
want: true,
},
{
name: "default profile when the account has no directory name and the claim is disabled",
dir: "",
profile: Profile{ID: defaultProfileName},
disabled: true,
want: false,
},
{
name: "shared-directory profile when the account has no directory name",
dir: "",
profile: Profile{ID: "work"},
want: false,
},
{
name: "profile in a real directory when the account has no directory name",
dir: "",
profile: Profile{ID: "work", LegacyUserDir: "alice"},
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.disabled {
t.Setenv(EnvDisableDefaultProfileClaim, "true")
}
assert.Equal(t, tc.want, takesActiveAccountOwner(&tc.profile, tc.dir))
})
}
}
@@ -48,10 +48,7 @@ func (p *Profile) AccessibleBy(id ipcauth.Identity) bool {
return true
}
if len(p.Owners) == 0 {
// A profile in a per-username directory belonged to a use, unowned fails
// closed. The account named by the directory reclaims it on their next
// lookup or until claimed by a privileged caller.
return p.LegacyUserDir == "" && p.ID == DefaultProfileName
return false
}
return p.Owners[0].Matches(id)
}
+67
View File
@@ -22,6 +22,11 @@ import (
"github.com/netbirdio/netbird/util"
)
// EnvDisableDefaultProfileClaim turns off the console-user claim of an unowned
// default profile. The profile then stays unowned until a privileged caller
// records an owner.
const EnvDisableDefaultProfileClaim = "NB_DISABLE_DEFAULT_PROFILE_CLAIM"
var (
oldDefaultConfigPathDir = ""
oldDefaultConfigPath = ""
@@ -596,6 +601,7 @@ func (s *ServiceManager) loadAllProfilesForIdentity(userID ipcauth.Identity) ([]
return nil, err
}
s.claimDefaultProfileIfNeeded(allProfiles, userID)
s.claimLegacyProfiles(allProfiles, userID)
accessible := make([]Profile, 0, len(allProfiles))
@@ -659,6 +665,67 @@ func (s *ServiceManager) claimLegacyProfiles(profiles []Profile, id ipcauth.Iden
}
}
func (s *ServiceManager) claimDefaultProfileIfNeeded(profiles []Profile, id ipcauth.Identity) {
if !id.Known() || ipcauth.IsPrivilegedCaller(id) {
return
}
var unowned bool
var p *Profile
for i := range profiles {
p = &profiles[i]
if p.ID == defaultProfileName && len(p.Owners) == 0 {
unowned = true
break
}
}
if unowned && !defaultProfileClaimDisabled() && isConsoleUser(id) {
principal := ipcauth.OwnerPrincipalForIdentity(id)
parsed, ok := ipcauth.ParsePrincipal(principal)
if !ok {
log.Warnf("not claiming default profile, %q is not a usable owner", principal)
return
}
if err := StampOwner(p.Path, id); err != nil {
log.Warnf("could not claim default profile %s for %#v: %v", p.Path, id, err)
return
}
p.Owners = []ipcauth.Principal{parsed}
log.Infof("claimed default profile %s for %s", p.Path, principal)
}
}
// isConsoleUser is a variable so a test can decide whether a caller is at the
// console without the machine running the test having a seat of its own.
var isConsoleUser = ipcauth.IsConsoleUser
// logDefaultClaimDisabledOrError keeps the notice to once per process, since the claim
// path runs on every profile load. It also logs a parse failure once.
var logDefaultClaimDisabledOrError sync.Once
// defaultProfileClaimDisabled reports whether the environment turns off the
// console-user claim of the default profile.
func defaultProfileClaimDisabled() bool {
val := os.Getenv(EnvDisableDefaultProfileClaim)
if val == "" {
return false
}
disabled, err := strconv.ParseBool(val)
if err != nil {
logDefaultClaimDisabledOrError.Do(func() {
log.Warnf("failed to parse %s: %v", EnvDisableDefaultProfileClaim, err)
})
return false
}
if disabled {
logDefaultClaimDisabledOrError.Do(func() {
log.Infof("%s is set, the default profile stays unowned and reachable only by a privileged caller until an owner is recorded another way", EnvDisableDefaultProfileClaim)
})
}
return disabled
}
func hasUnownedLegacyProfile(profiles []Profile) bool {
for i := range profiles {
if profiles[i].LegacyUserDir != "" && len(profiles[i].Owners) == 0 {
+71 -6
View File
@@ -291,24 +291,30 @@ func TestListProfiles_PrivilegedResolvesUnfiltered(t *testing.T) {
})
}
func TestListProfiles_OnlyTheDefaultFailsOpenWhenUnowned(t *testing.T) {
func TestListProfiles_UnownedProfilesArePrivilegedOnly(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
// Nobody at the console, so the claim cannot stamp an owner partway
// through and change what the assertions below are looking at, whatever
// the machine running the test happens to look like.
stubConsoleUser(t, false)
unowned, err := sm.AddProfile("unowned", nil)
require.NoError(t, err)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), defaultProfileName,
"a fresh install has to be usable before anything is claimed")
assert.NotContains(t, profileIDs(got), defaultProfileName,
"the default profile has no exemption, being claimed is what opens it")
assert.NotContains(t, profileIDs(got), unowned.ID.String(),
"every other profile needs an owner before anyone can address it")
"every profile needs an owner before anyone can address it")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), unowned.ID.String(),
"root still reaches it, which is how it gets assigned")
assert.Contains(t, profileIDs(got), defaultProfileName,
"root still reaches both, which is how an unowned profile gets assigned")
assert.Contains(t, profileIDs(got), unowned.ID.String())
nobody, err := sm.ListProfiles(ipcauth.Identity{})
require.NoError(t, err)
@@ -689,6 +695,27 @@ func TestListProfiles_ClaimKeepsFieldsThisVersionDoesNotModel(t *testing.T) {
})
}
// stubConsoleUser replaces the console lookup, so the default-profile claim can
// be exercised without the machine running the test having a seat of its own.
func stubConsoleUser(t *testing.T, atConsole bool) {
t.Helper()
orig := isConsoleUser
isConsoleUser = func(ipcauth.Identity) bool { return atConsole }
t.Cleanup(func() { isConsoleUser = orig })
}
func TestClaimDefaultProfile_ConsoleUserClaimsIt(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, _ string) {
stubConsoleUser(t, true)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, DefaultConfigPath),
"the first caller at the console closes the window the default profile is open in")
})
}
func TestSetProfileField_ReplacesAKeySpelledInAnotherCase(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
@@ -760,3 +787,41 @@ func TestSetProfileField_KeepsKeysItWasNotAskedToWrite(t *testing.T) {
assert.Len(t, doc, len(unknown)+3, "with nothing else added")
})
}
func TestClaimDefaultProfile_CallerAwayFromTheConsoleDoesNotClaimIt(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, _ string) {
stubConsoleUser(t, false)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Empty(t, readOwners(t, DefaultConfigPath),
"a local caller who is not at the console must not take the machine's profile")
})
}
func TestClaimDefaultProfile_DisableEnvWithholdsTheClaim(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, _ string) {
stubConsoleUser(t, true)
t.Setenv(EnvDisableDefaultProfileClaim, "true")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Empty(t, readOwners(t, DefaultConfigPath),
"the flag withholds the claim even from a caller who would otherwise get it")
})
}
func TestClaimDefaultProfile_UnparseableDisableEnvLeavesTheClaimOn(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, _ string) {
stubConsoleUser(t, true)
t.Setenv(EnvDisableDefaultProfileClaim, "yes please")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, DefaultConfigPath),
"a typo must not be what turns a safety mechanism off")
})
}