mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-20 21:23:07 +08:00
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:
+110
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user