diff --git a/feature/taildrop/ext.go b/feature/taildrop/ext.go index 3a4ed456d..abf574ebc 100644 --- a/feature/taildrop/ext.go +++ b/feature/taildrop/ext.go @@ -139,8 +139,8 @@ func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsVie e.mu.Lock() defer e.mu.Unlock() - uid := profile.UserProfile().ID - activeLogin := profile.UserProfile().LoginName + uid := profile.UserProfile().ID() + activeLogin := profile.UserProfile().LoginName() if uid == 0 { e.setMgrLocked(nil) diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 3e6cbbb82..e179438cd 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -24,6 +24,7 @@ func (src *LoginProfile) Clone() *LoginProfile { } dst := new(LoginProfile) *dst = *src + dst.UserProfile = *src.UserProfile.Clone() return dst } diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 90560cec0..4e9d46bda 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -113,7 +113,7 @@ func (v LoginProfileView) Key() StateKey { return v.ж.Key } // UserProfile is the server provided UserProfile for this profile. // This is updated whenever the server provides a new UserProfile. -func (v LoginProfileView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } +func (v LoginProfileView) UserProfile() tailcfg.UserProfileView { return v.ж.UserProfile.View() } // NodeID is the NodeID of the node that this profile is logged into. // This should be stable across tagging and untagging nodes. diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 5f694e915..77bb14f36 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -4689,7 +4689,7 @@ func (b *LocalBackend) setPrefsLocked(newp *ipn.Prefs) ipn.PrefsView { if !oldp.Persist().Valid() { b.logf("active login: %s", newLoginName) } else { - oldLoginName := oldp.Persist().UserProfile().LoginName + oldLoginName := oldp.Persist().UserProfile().LoginName() if oldLoginName != newLoginName { b.logf("active login: %q (changed from %q)", newLoginName, oldLoginName) } diff --git a/ipn/ipnlocal/profiles.go b/ipn/ipnlocal/profiles.go index 430fa6315..4e073e5c9 100644 --- a/ipn/ipnlocal/profiles.go +++ b/ipn/ipnlocal/profiles.go @@ -274,7 +274,7 @@ func (pm *profileManager) matchingProfiles(uid ipn.WindowsUserID, f func(ipn.Log func (pm *profileManager) findMatchingProfiles(uid ipn.WindowsUserID, prefs ipn.PrefsView) []ipn.LoginProfileView { return pm.matchingProfiles(uid, func(p ipn.LoginProfileView) bool { return p.ControlURL() == prefs.ControlURL() && - (p.UserProfile().ID == prefs.Persist().UserProfile().ID || + (p.UserProfile().ID() == prefs.Persist().UserProfile().ID() || p.NodeID() == prefs.Persist().NodeID()) }) } @@ -337,7 +337,7 @@ func (pm *profileManager) setUnattendedModeAsConfigured() error { // across user switches to disambiguate the same account but a different tailnet. func (pm *profileManager) SetPrefs(prefsIn ipn.PrefsView, np ipn.NetworkProfile) error { cp := pm.currentProfile - if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName == "" { + if persist := prefsIn.Persist(); !persist.Valid() || persist.NodeID() == "" || persist.UserProfile().LoginName() == "" { // We don't know anything about this profile, so ignore it for now. return pm.setProfilePrefsNoPermCheck(pm.currentProfile, prefsIn.AsStruct().View()) } @@ -410,7 +410,7 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref // and it hasn't been persisted yet. We'll generate both an ID and [ipn.StateKey] // once the information is available and needs to be persisted. if lp.ID == "" { - if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName != "" { + if persist := prefsIn.Persist(); persist.Valid() && persist.NodeID() != "" && persist.UserProfile().LoginName() != "" { // Generate an ID and [ipn.StateKey] now that we have the node info. lp.ID, lp.Key = newUnusedID(pm.knownProfiles) } @@ -425,7 +425,7 @@ func (pm *profileManager) setProfilePrefs(lp *ipn.LoginProfile, prefsIn ipn.Pref var up tailcfg.UserProfile if persist := prefsIn.Persist(); persist.Valid() { - up = persist.UserProfile() + up = *persist.UserProfile().AsStruct() if up.DisplayName == "" { up.DisplayName = up.LoginName } diff --git a/ipn/ipnlocal/state_test.go b/ipn/ipnlocal/state_test.go index 39796ec32..ab09e0a09 100644 --- a/ipn/ipnlocal/state_test.go +++ b/ipn/ipnlocal/state_test.go @@ -606,7 +606,7 @@ func runTestStateMachine(t *testing.T, seamless bool) { cc.assertCalls() c.Assert(nn[0].LoginFinished, qt.IsNotNil) c.Assert(nn[1].Prefs, qt.IsNotNil) - c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName, qt.Equals, "user1") + c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName(), qt.Equals, "user1") // nn[2] is a state notification after login // Verify login finished but need machine auth using backend state c.Assert(isFullyAuthenticated(b), qt.IsTrue) @@ -818,7 +818,7 @@ func runTestStateMachine(t *testing.T, seamless bool) { c.Assert(nn[1].Prefs, qt.IsNotNil) c.Assert(nn[1].Prefs.Persist(), qt.IsNotNil) // Prefs after finishing the login, so LoginName updated. - c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName, qt.Equals, "user2") + c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName(), qt.Equals, "user2") c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse) // If a user initiates an interactive login, they also expect WantRunning to become true. c.Assert(nn[1].Prefs.WantRunning(), qt.IsTrue) @@ -964,7 +964,7 @@ func runTestStateMachine(t *testing.T, seamless bool) { c.Assert(nn[0].LoginFinished, qt.IsNotNil) c.Assert(nn[1].Prefs, qt.IsNotNil) // Prefs after finishing the login, so LoginName updated. - c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName, qt.Equals, "user3") + c.Assert(nn[1].Prefs.Persist().UserProfile().LoginName(), qt.Equals, "user3") c.Assert(nn[1].Prefs.LoggedOut(), qt.IsFalse) c.Assert(nn[1].Prefs.WantRunning(), qt.IsTrue) // nn[2] is state notification (Starting) - verify using backend state diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index b49791be6..1efa6c959 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -282,6 +282,13 @@ type UserProfile struct { LoginName string // "alice@smith.com"; for display purposes only (provider is not listed) DisplayName string // "Alice Smith" ProfilePicURL string `json:",omitzero"` + + // Groups is a subset of SCIM groups (e.g. "engineering@example.com") + // or group names in the tailnet policy document (e.g. "group:eng") + // that contain this user and that the coordination server was + // configured to report to this node. + // The list is always sorted when loaded from storage. + Groups []string `json:",omitempty"` } func (p *UserProfile) Equal(p2 *UserProfile) bool { @@ -294,7 +301,8 @@ func (p *UserProfile) Equal(p2 *UserProfile) bool { return p.ID == p2.ID && p.LoginName == p2.LoginName && p.DisplayName == p2.DisplayName && - p.ProfilePicURL == p2.ProfilePicURL + p.ProfilePicURL == p2.ProfilePicURL && + slices.Equal(p.Groups, p2.Groups) } // RawMessage is a raw encoded JSON value. It implements Marshaler and diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 191170723..8b966b621 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -620,6 +620,7 @@ func (src *UserProfile) Clone() *UserProfile { } dst := new(UserProfile) *dst = *src + dst.Groups = append(src.Groups[:0:0], src.Groups...) return dst } @@ -629,6 +630,7 @@ var _UserProfileCloneNeedsRegeneration = UserProfile(struct { LoginName string DisplayName string ProfilePicURL string + Groups []string }{}) // Clone makes a deep copy of VIPService. diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 7960000fd..9900efbcc 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -2505,8 +2505,15 @@ func (v UserProfileView) ID() UserID { return v.ж.ID } func (v UserProfileView) LoginName() string { return v.ж.LoginName } // "Alice Smith" -func (v UserProfileView) DisplayName() string { return v.ж.DisplayName } -func (v UserProfileView) ProfilePicURL() string { return v.ж.ProfilePicURL } +func (v UserProfileView) DisplayName() string { return v.ж.DisplayName } +func (v UserProfileView) ProfilePicURL() string { return v.ж.ProfilePicURL } + +// Groups is a subset of SCIM groups (e.g. "engineering@example.com") +// or group names in the tailnet policy document (e.g. "group:eng") +// that contain this user and that the coordination server was +// configured to report to this node. +// The list is always sorted when loaded from storage. +func (v UserProfileView) Groups() views.Slice[string] { return views.SliceOf(v.ж.Groups) } func (v UserProfileView) Equal(v2 UserProfileView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. @@ -2515,6 +2522,7 @@ var _UserProfileViewNeedsRegeneration = UserProfile(struct { LoginName string DisplayName string ProfilePicURL string + Groups []string }{}) // View returns a read-only view of VIPService. diff --git a/types/persist/persist_clone.go b/types/persist/persist_clone.go index f5fa36b6d..b43dcc7fd 100644 --- a/types/persist/persist_clone.go +++ b/types/persist/persist_clone.go @@ -19,6 +19,7 @@ func (src *Persist) Clone() *Persist { } dst := new(Persist) *dst = *src + dst.UserProfile = *src.UserProfile.Clone() if src.AttestationKey != nil { dst.AttestationKey = src.AttestationKey.Clone() } diff --git a/types/persist/persist_view.go b/types/persist/persist_view.go index b18634917..f33d222c6 100644 --- a/types/persist/persist_view.go +++ b/types/persist/persist_view.go @@ -90,7 +90,7 @@ func (v PersistView) PrivateNodeKey() key.NodePrivate { return v.ж.PrivateNodeK // needed to request key rotation func (v PersistView) OldPrivateNodeKey() key.NodePrivate { return v.ж.OldPrivateNodeKey } -func (v PersistView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile } +func (v PersistView) UserProfile() tailcfg.UserProfileView { return v.ж.UserProfile.View() } func (v PersistView) NetworkLockKey() key.NLPrivate { return v.ж.NetworkLockKey } func (v PersistView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID } func (v PersistView) AttestationKey() tailcfg.StableNodeID { panic("unsupported") }