ipn/{desktop,ipnlocal}: register per-user policy stores on session lifecycle

This registers per-user RSOP policy stores when a user logs into a Windows session and ensures that per-user poicy settings are delivered to clients via IPN bus.

The store lifecycle is tied to Windows session lifetime via desktopSessionExt's SessionInitCallback, which handles unattended mode (per-user policies stay enforced after gui disconnection) and multi-user (refcounted across sessions for the same user).

Updates tailscale/corp#42259

Signed-off-by: kari-ts <[email protected]>
This commit is contained in:
kari-ts
2026-06-30 16:03:53 -07:00
parent 07cefc083d
commit 90933da4b4
4 changed files with 304 additions and 6 deletions
+110 -1
View File
@@ -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.
+116
View File
@@ -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)
}
+13 -4
View File
@@ -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)
}
}
+65 -1
View File
@@ -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