From c14f706404ba46e4f7bd774d64b7cdc41b286b89 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 29 Jul 2026 13:00:13 +0000 Subject: [PATCH] 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 Change-Id: Ia4c4336d211e489dd268b428aad3a0bf55e70ad8 --- ipn/ipnlocal/profiles.go | 12 ++++++++ ipn/ipnlocal/profiles_test.go | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/ipn/ipnlocal/profiles.go b/ipn/ipnlocal/profiles.go index 5619f0639..ed02ed5f6 100644 --- a/ipn/ipnlocal/profiles.go +++ b/ipn/ipnlocal/profiles.go @@ -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. diff --git a/ipn/ipnlocal/profiles_test.go b/ipn/ipnlocal/profiles_test.go index 8ac22a656..a29ce675e 100644 --- a/ipn/ipnlocal/profiles_test.go +++ b/ipn/ipnlocal/profiles_test.go @@ -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) {