ipn/ipnlocal: keep operator user when switching to a new profile

"tailscale login" first switches to a new empty profile before
starting the login flow. Switching reset the prefs to defaults,
clearing OperatorUser, so a non-root operator lost LocalAPI write
access partway through the command: the profile switch itself
succeeded, then the following check-prefs request failed with
"checkprefs access denied". Worse, the operator setting was lost
entirely, so subsequent attempts failed earlier with "profiles
access denied". This made the long-suggested advice of running
"sudo tailscale set --operator=$USER" not work for logging in.

Carry OperatorUser over to the new profile's prefs. It is a
machine-local administrative setting rather than an account setting,
and only actors that already have write access can initiate a
profile switch, so this grants no new access.

Fixes #18294

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: Ia4c4336d211e489dd268b428aad3a0bf55e70ad8
This commit is contained in:
Brad Fitzpatrick committed 2026-07-29 06:01:14 -07:00
1 parent 7dec0c7d7a
commit c14f706404
2 files changed
+65

No files matched your search

+12
View File
@@ -139,6 +139,18 @@ func (pm *profileManager) SetCurrentUserID(uid ipn.WindowsUserID) {
// and must check whether pm.currentProfile is Valid before using it.
func (pm *profileManager) SwitchToProfile(profile ipn.LoginProfileView) (cp ipn.LoginProfileView, changed bool, err error) {
prefs := defaultPrefs
if pm.prefs.Valid() && pm.prefs.OperatorUser() != "" {
// Carry the operator over to a new profile. The operator is a
// machine-local administrative setting rather than an account
// setting, and any actor able to initiate a profile switch
// already has write access. Without this, an operator running
// "tailscale login" would lose access partway through the
// login flow when it switches to a new empty profile.
// See tailscale/tailscale#18294.
p := prefs.AsStruct()
p.OperatorUser = pm.prefs.OperatorUser()
prefs = p.View()
}
switch {
case !profile.Valid():
// Create a new profile that is not associated with any user.
+53
View File
@@ -492,6 +492,59 @@ func TestProfileManagement(t *testing.T) {
}
}
// TestSwitchToNewProfileKeepsOperator tests that the operator user carries
// over when switching to a new, empty profile, as during "tailscale login".
// See tailscale/tailscale#18294: switching to a new profile used to reset
// the prefs to defaults, locking the operator out partway through the login
// flow and losing the operator setting entirely.
func TestSwitchToNewProfileKeepsOperator(t *testing.T) {
store := new(mem.Store)
pm, err := newProfileManagerWithGOOS(store, logger.Discard, health.NewTracker(eventbustest.NewBus(t)), "linux")
if err != nil {
t.Fatal(err)
}
// Set the operator on the initial empty profile,
// as "sudo tailscale set --operator=$USER" would.
prefs := pm.CurrentPrefs().AsStruct()
prefs.OperatorUser = "operator"
if err := pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
pm.SwitchToNewProfile()
if got := pm.CurrentPrefs().OperatorUser(); got != "operator" {
t.Errorf("OperatorUser after switch from empty profile = %q; want %q", got, "operator")
}
// Log in and change another pref, then switch to a new profile again.
// The operator should carry over, but other prefs should reset.
prefs = pm.CurrentPrefs().AsStruct()
prefs.Persist = &persist.Persist{
PrivateNodeKey: key.NewNode(),
UserProfile: tailcfg.UserProfile{
ID: 1,
LoginName: "user@example.com",
},
NodeID: "node1",
}
prefs.Hostname = "buffalo"
if err := pm.SetPrefs(prefs.View(), ipn.NetworkProfile{}); err != nil {
t.Fatal(err)
}
pm.SwitchToNewProfile()
if id := pm.CurrentProfile().ID(); id != "" {
t.Errorf("CurrentProfile().ID() after switch = %q; want empty", id)
}
if got := pm.CurrentPrefs().OperatorUser(); got != "operator" {
t.Errorf("OperatorUser after switch from logged-in profile = %q; want %q", got, "operator")
}
if got := pm.CurrentPrefs().Hostname(); got != "" {
t.Errorf("Hostname after switch = %q; want empty", got)
}
}
// TestProfileManagementWindows tests going into and out of Unattended mode on
// Windows.
func TestProfileManagementWindows(t *testing.T) {