diff --git a/ipn/desktop/extension.go b/ipn/desktop/extension.go index 23e265401..17195f965 100644 --- a/ipn/desktop/extension.go +++ b/ipn/desktop/extension.go @@ -12,14 +12,19 @@ package desktop import ( "cmp" "fmt" + "io" "sync" + "golang.org/x/sys/windows" "tailscale.com/feature" "tailscale.com/ipn" "tailscale.com/ipn/ipnext" "tailscale.com/types/logger" "tailscale.com/util/syspolicy/pkey" "tailscale.com/util/syspolicy/policyclient" + "tailscale.com/util/syspolicy/rsop" + "tailscale.com/util/syspolicy/setting" + "tailscale.com/util/syspolicy/source" ) // featureName is the name of the feature implemented by this package. @@ -48,6 +53,16 @@ type desktopSessionsExt struct { // mu protects all following fields. mu sync.Mutex sessByID map[SessionID]*Session + + // userPolicyStores tracks per-user RSOP policy store registrations, + // refcounted by the number of active sessions for each user + userPolicyStores map[string]*userPolicyStore +} + +type userPolicyStore struct { + refcount int + store source.Store + reg *rsop.StoreRegistration } // newDesktopSessionsExt returns a new [desktopSessionsExt], @@ -78,8 +93,12 @@ func (e *desktopSessionsExt) Init(host ipnext.Host) (err error) { if err != nil { return fmt.Errorf("session callback registration failed: %w", err) } + unregisterPolicyCb, err := e.sm.RegisterInitCallback(e.initUserPolicyStore) + if err != nil { + return fmt.Errorf("policy store callback registration failed: %w", err) + } host.Hooks().BackgroundProfileResolvers.Add(e.getBackgroundProfile) - e.cleanup = []func(){unregisterSessionCb} + e.cleanup = []func(){unregisterSessionCb, unregisterPolicyCb} return nil } @@ -118,6 +137,96 @@ func (e *desktopSessionsExt) updateDesktopSessionState(session *Session) { e.host.Profiles().SwitchToBestProfileAsync(reason) } +// initUserPolicyStore is a [SessionInitCallback] that registers a +// per-user policy store when a user logs in. The returned cleanup +// function releases the store when the session is destroyed. +func (e *desktopSessionsExt) initUserPolicyStore(session *Session) func() { + uid := string(session.User.UserID()) + if uid == "" { + return nil + } + + var token windows.Token + if err := windows.WTSQueryUserToken(uint32(session.ID), &token); err != nil { + e.logf("failed to get user token for session %d: %v", session.ID, err) + return nil + } + defer token.Close() + + store, err := source.NewUserPlatformPolicyStore(token) + if err != nil { + e.logf("failed to create user policy store for %s: %v", uid, err) + return nil + } + + if err := e.ensureUserPolicyStore(uid, store); err != nil { + e.logf("failed to register user policy store for %s: %v", uid, err) + return nil + } + + e.logf("registered user policy store for %s (session %d)", uid, session.ID) + return func() { + e.releaseUserPolicyStore(uid) + e.logf("released user policy store for %s (session %d)", uid, session.ID) + } +} + +// ensureUserPolicyStore registers a per-user policy store with RSOP. +// If a store is already registered for this user (from another session), +// it increments the refcount and closes the provided store. +func (e *desktopSessionsExt) ensureUserPolicyStore(uid string, store source.Store) error { + e.mu.Lock() + defer e.mu.Unlock() + + if ups, ok := e.userPolicyStores[uid]; ok { + ups.refcount++ + if c, ok := store.(io.Closer); ok { + c.Close() + } + return nil + } + + scope := setting.UserScopeOf(uid) + reg, err := rsop.RegisterStore("Platform", scope, store) + if err != nil { + if c, ok := store.(io.Closer); ok { + c.Close() + } + return err + } + + if e.userPolicyStores == nil { + e.userPolicyStores = make(map[string]*userPolicyStore) + } + e.userPolicyStores[uid] = &userPolicyStore{ + refcount: 1, + store: store, + reg: reg, + } + return nil +} + +// releaseUserPolicyStore decrements the refcount for a user's policy +// store. At zero, the store is unregistered from RSOP and closed. +func (e *desktopSessionsExt) releaseUserPolicyStore(uid string) { + e.mu.Lock() + defer e.mu.Unlock() + + ups, ok := e.userPolicyStores[uid] + if !ok { + return + } + ups.refcount-- + if ups.refcount > 0 { + return + } + ups.reg.Unregister() + if c, ok := ups.store.(io.Closer); ok { + c.Close() + } + delete(e.userPolicyStores, uid) +} + // getBackgroundProfile is a [ipnext.ProfileResolver] that works as follows: // // If Always-On mode is disabled, it returns no profile. diff --git a/ipn/desktop/extension_test.go b/ipn/desktop/extension_test.go new file mode 100644 index 000000000..a5f059a0d --- /dev/null +++ b/ipn/desktop/extension_test.go @@ -0,0 +1,116 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Both the desktop session manager and multi-user support +// are currently available only on Windows. +// This file does not need to be built for other platforms. + +//go:build windows && !ts_omit_desktop_sessions + +package desktop + +import ( + "fmt" + "testing" + + "tailscale.com/util/syspolicy/pkey" + "tailscale.com/util/syspolicy/rsop" + "tailscale.com/util/syspolicy/setting" + "tailscale.com/util/syspolicy/source" +) + +func TestUserPolicyStoreRefcount(t *testing.T) { + setting.SetDefinitionsForTest(t, + setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue), + ) + ext := &desktopSessionsExt{ + logf: t.Logf, + } + + uid := "S-1-5-21-1001" + scope := setting.UserScopeOf(uid) + + store1 := source.NewTestStore(t) + store1.SetStrings(source.TestSettingOf("AdminConsole", "hide")) + if err := ext.ensureUserPolicyStore(uid, store1); err != nil { + t.Fatalf("first ensureUserPolicyStore: %v", err) + } + + policy, err := rsop.PolicyFor(scope) + if err != nil { + t.Fatalf("PolicyFor(%v): %v", scope, err) + } + snap := policy.Get() + if got := snap.Get("AdminConsole"); got == nil { + t.Error("expected AdminConsole in snapshot after first registration") + } + + // Second registration for the same uid should increment refcount + // and close the duplicate store. + store2 := source.NewTestStore(t) + if err := ext.ensureUserPolicyStore(uid, store2); err != nil { + t.Fatalf("second ensureUserPolicyStore: %v", err) + } + + // First release should decrement but keep the store alive. + ext.releaseUserPolicyStore(uid) + if _, ok := ext.userPolicyStores[uid]; !ok { + t.Fatal("store removed after first release, expected refcount=1") + } + + // Second release should clean up. + ext.releaseUserPolicyStore(uid) + if _, ok := ext.userPolicyStores[uid]; ok { + t.Fatal("store still present after final release") + } + + ext.releaseUserPolicyStore("S-1-5-21-unknown") +} + +func TestUserPolicyStoreMultipleUsers(t *testing.T) { + setting.SetDefinitionsForTest(t, + setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue), + ) + ext := &desktopSessionsExt{ + logf: t.Logf, + } + + uidA := "S-1-5-21-1001" + uidB := "S-1-5-21-1002" + + storeA := source.NewTestStore(t) + storeA.SetStrings(source.TestSettingOf("AdminConsole", "hide")) + + storeB := source.NewTestStore(t) + storeB.SetStrings(source.TestSettingOf("AdminConsole", "show")) + + if err := ext.ensureUserPolicyStore(uidA, storeA); err != nil { + t.Fatalf("ensureUserPolicyStore(A): %v", err) + } + if err := ext.ensureUserPolicyStore(uidB, storeB); err != nil { + t.Fatalf("ensureUserPolicyStore(B): %v", err) + } + + policyA, err := rsop.PolicyFor(setting.UserScopeOf(uidA)) + if err != nil { + t.Fatalf("PolicyFor(A): %v", err) + } + policyB, err := rsop.PolicyFor(setting.UserScopeOf(uidB)) + if err != nil { + t.Fatalf("PolicyFor(B): %v", err) + } + + if got := fmt.Sprint(policyA.Get().Get("AdminConsole")); got != "hide" { + t.Errorf("user A AdminConsole = %v; want hide", got) + } + if got := fmt.Sprint(policyB.Get().Get("AdminConsole")); got != "show" { + t.Errorf("user B AdminConsole = %v; want show", got) + } + + ext.releaseUserPolicyStore(uidA) + if _, ok := ext.userPolicyStores[uidB]; !ok { + t.Fatal("user B store removed when A was released") + } + + ext.releaseUserPolicyStore(uidB) +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index aab2b792e..4b90a533c 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -190,6 +190,10 @@ type watchSession struct { // boundary for implicit bus state, so per-session dedup state such as // lastSentUserProfile must be reset when this identity changes. lastSentSelf tailcfg.NodeView + + // policyUID is the user ID used for policy snapshot scoping. + // Empty string means default/device scope. + policyUID string } var ( @@ -3733,6 +3737,7 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A deadlockDone := b.CheckDeadlocks() b.mu.Lock() + var policyUID string const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | @@ -3781,10 +3786,13 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A } } if mask&ipn.NotifySysPolicyChanges != 0 { + if actor != nil { + policyUID = string(actor.UserID()) + } var err error - ini.Policy, err = b.polc.GetPolicySnapshot("") + ini.Policy, err = b.polc.GetPolicySnapshot(policyUID) if err != nil { - b.logf("syspolicy: GetPolicySnapshot(\"\"): %v", err) + b.logf("syspolicy: GetPolicySnapshot(%q): %v", policyUID, err) } } } @@ -3799,6 +3807,7 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A sessionID: sessionID, cancel: cancel, mask: mask, + policyUID: policyUID, } mak.Set(&b.notifyWatchers, sessionID, session) if mask&ipn.NotifyPeerWireGuardState != 0 { @@ -3808,12 +3817,12 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A deadlockDone() if mask&ipn.NotifySysPolicyChanges != 0 { - if unreg, err := b.polc.RegisterChangeCallback("", func(_ policyclient.PolicyChange) { + if unreg, err := b.polc.RegisterChangeCallback(policyUID, func(_ policyclient.PolicyChange) { b.sysPolicyChangedForSession(session) }); err == nil { defer unreg() } else { - b.logf("syspolicy: RegisterChangeCallback(\"\"): %v", err) + b.logf("syspolicy: RegisterChangeCallback(%q): %v", policyUID, err) } } diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 34e36fab3..eaffead18 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "maps" "math" "net" @@ -8614,7 +8615,9 @@ func (testPolicyClient) GetPolicySnapshot(uid string) (*policyclient.PolicySnaps if err != nil { return nil, err } - return p.Get(), nil + snap := p.Get() + log.Printf("GetPolicySnapshot(%q): scope=%v snap=%v", uid, scope, snap) + return snap, nil } func (testPolicyClient) RegisterChangeCallback(uid string, cb func(policyclient.PolicyChange)) (func(), error) { @@ -8631,6 +8634,67 @@ func (testPolicyClient) RegisterChangeCallback(uid string, cb func(policyclient. }), nil } +func TestPolicySnapshotMergesDeviceAndUserScopes(t *testing.T) { + setting.SetDefinitionsForTest(t, + setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue), + setting.NewDefinition(pkey.ExitNodeMenuVisibility, setting.UserSetting, setting.VisibilityValue), + setting.NewDefinition(pkey.ManagedByOrganizationName, setting.UserSetting, setting.StringValue), + ) + + deviceStore := source.NewTestStore(t) + deviceStore.SetStrings( + source.TestSettingOf(pkey.AdminConsoleVisibility, "hide"), + source.TestSettingOf(pkey.ManagedByOrganizationName, "DeviceCorp"), + ) + rsop.RegisterStoreForTest(t, "DeviceStore", setting.DeviceScope, deviceStore) + + uid := "S-1-5-21-1001" + userStore := source.NewTestStore(t) + userStore.SetStrings( + source.TestSettingOf(pkey.AdminConsoleVisibility, "show"), + source.TestSettingOf(pkey.ExitNodeMenuVisibility, "hide"), + ) + rsop.RegisterStoreForTest(t, "UserStore", setting.UserScopeOf(uid), userStore) + + sys := tsd.NewSystem() + sys.PolicyClient.Set(testPolicyClient{}) + lb := newTestLocalBackendWithSys(t, sys) + + snap, err := rsop.PolicyFor(setting.UserScopeOf(uid)) + if err != nil { + t.Fatalf("PolicyFor: %v", err) + } + t.Logf("direct PolicyFor snapshot: %v", snap.Get()) + + nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{UID: ipn.WindowsUserID(uid)}) + nw.watch(ipn.NotifySysPolicyChanges, []wantedNotification{ + { + name: "MergedPolicy", + cond: func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool { + if n.Policy == nil { + return false + } + + // Device scope should win on conflict. + if got := fmt.Sprint(n.Policy.Get(pkey.AdminConsoleVisibility)); got != "hide" { + t.Errorf("AdminConsole = %v; want hide (device wins)", got) + } + + if got := fmt.Sprint(n.Policy.Get(pkey.ExitNodeMenuVisibility)); got != "hide" { + t.Errorf("ExitNodesPicker = %v; want hide (from user)", got) + } + + if got := fmt.Sprint(n.Policy.Get(pkey.ManagedByOrganizationName)); got != "DeviceCorp" { + t.Errorf("ManagedByOrganizationName = %v; want DeviceCorp", got) + } + + return true + }, + }, + }) + nw.check() +} + type textUpdate struct { Advertise []string Unadvertise []string