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>
main
Gesa Stupperich 2 months ago committed by Gesa Stupperich
parent ac74dfa5cd
commit 6a19995f13
  1. 4
      feature/taildrop/ext.go
  2. 1
      ipn/ipn_clone.go
  3. 2
      ipn/ipn_view.go
  4. 2
      ipn/ipnlocal/local.go
  5. 8
      ipn/ipnlocal/profiles.go
  6. 6
      ipn/ipnlocal/state_test.go
  7. 10
      tailcfg/tailcfg.go
  8. 2
      tailcfg/tailcfg_clone.go
  9. 12
      tailcfg/tailcfg_view.go
  10. 1
      types/persist/persist_clone.go
  11. 2
      types/persist/persist_view.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)

@ -24,6 +24,7 @@ func (src *LoginProfile) Clone() *LoginProfile {
}
dst := new(LoginProfile)
*dst = *src
dst.UserProfile = *src.UserProfile.Clone()
return dst
}

@ -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.

@ -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)
}

@ -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
}

@ -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

@ -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

@ -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.

@ -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.

@ -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()
}

@ -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") }

Loading…
Cancel
Save