tailcfg: reintroduce UserProfile.Groups

This change reintroduces UserProfile.Groups, a slice that contains
the ACL-defined and synced groups that a user is a member of.

The slice will only be non-nil for clients with the node attribute
see-groups, and will only contain groups that the client is allowed
to see as per the app payload of the see-groups node attribute.

For example:
```
"nodeAttrs": [
  {
    "target": ["tag:dev"],
    "app": {
      "tailscale.com/see-groups": [{"groups": ["group:dev"]}]
    }
  },

  [...]

]
```

UserProfile.Groups will also be gated by a feature flag for the time
being.

Updates tailscale/corp#31529

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
This commit is contained in:
Gesa Stupperich
2026-02-17 13:15:02 +00:00
committed by Gesa Stupperich
parent ac74dfa5cd
commit 6a19995f13
11 changed files with 35 additions and 15 deletions
+1
View File
@@ -24,6 +24,7 @@ func (src *LoginProfile) Clone() *LoginProfile {
}
dst := new(LoginProfile)
*dst = *src
dst.UserProfile = *src.UserProfile.Clone()
return dst
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)
}
+4 -4
View File
@@ -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
}
+3 -3
View File
@@ -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