ipn/{ipn,ipnlocal}: add per-user policy snapshots to IPN bus (#20135)

This adds the NotifyInitialPolicy watch option and the Policy field in
Notify so that clients can receive the effective policy snapshot via IPN
bus.

This extends policyclient.Client so ipnlocal can get and watch policy
snapshots, which is used by sysPolicyChanged to notify watchers.

User-scoped policy store registration, management, and cleanup will be
added in a follow-up

Updates tailscale/corp#42259

Signed-off-by: kari <kari@tailscale.com>
This commit is contained in:
kari-ts
2026-06-30 12:44:29 -07:00
committed by GitHub
parent fad8b9b8a9
commit 07cefc083d
11 changed files with 284 additions and 15 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/syspolicy/pkey from tailscale.com/ipn+ tailscale.com/util/syspolicy/pkey from tailscale.com/ipn+
tailscale.com/util/syspolicy/policyclient from tailscale.com/ipn tailscale.com/util/syspolicy/policyclient from tailscale.com/ipn
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy/policyclient+ tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy/policyclient+
tailscale.com/util/syspolicy/setting from tailscale.com/client/local tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
tailscale.com/util/testenv from tailscale.com/net/bakedroots+ tailscale.com/util/testenv from tailscale.com/net/bakedroots+
tailscale.com/util/usermetric from tailscale.com/health tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+ tailscale.com/util/vizerror from tailscale.com/tailcfg+
+21
View File
@@ -19,6 +19,7 @@ import (
"tailscale.com/types/netmap" "tailscale.com/types/netmap"
"tailscale.com/types/structs" "tailscale.com/types/structs"
"tailscale.com/types/views" "tailscale.com/types/views"
"tailscale.com/util/syspolicy/policyclient"
) )
type State int type State int
@@ -173,6 +174,18 @@ const (
// not request it. // not request it.
NotifyInProcessNoDisconnect NotifyWatchOpt = 1 << 16 NotifyInProcessNoDisconnect NotifyWatchOpt = 1 << 16
// NotifySysPolicyChanges, if set, causes the first Notify message, which is sent
// immediately, to contain the current effective [setting.Snapshot] in
// [Notify.Policy]. [Notify.Policy] is included in subsequent messages whenever
// the effective policy changes.
//
// The snapshot is scoped to the connected user's identity (on Windows,
// derived from the named-pipe token's SID).
//
// The [setting.Snapshot] that is delivered is a full snapshot on every
// change.
NotifySysPolicyChanges NotifyWatchOpt = 1 << 17
// NotifyPeerWireGuardState, if set, opts the watcher into // NotifyPeerWireGuardState, if set, opts the watcher into
// WireGuard session state notifications via [Notify.PeerState]. // WireGuard session state notifications via [Notify.PeerState].
// The first Notify sent to the watcher includes a dump of current // The first Notify sent to the watcher includes a dump of current
@@ -220,6 +233,7 @@ func (o NotifyWatchOpt) String() string {
try(NotifyInitialStatus, "NotifyInitialStatus") try(NotifyInitialStatus, "NotifyInitialStatus")
try(NotifyPeerPatches, "NotifyPeerPatches") try(NotifyPeerPatches, "NotifyPeerPatches")
try(NotifyInProcessNoDisconnect, "NotifyInProcessNoDisconnect") try(NotifyInProcessNoDisconnect, "NotifyInProcessNoDisconnect")
try(NotifySysPolicyChanges, "NotifySysPolicyChanges")
try(NotifyPeerWireGuardState, "NotifyPeerWireGuardState") try(NotifyPeerWireGuardState, "NotifyPeerWireGuardState")
if mask != o { if mask != o {
@@ -453,6 +467,13 @@ type Notify struct {
// be the best exit node for the current network conditions. // be the best exit node for the current network conditions.
SuggestedExitNode *tailcfg.StableNodeID `json:",omitzero"` SuggestedExitNode *tailcfg.StableNodeID `json:",omitzero"`
// Policy, if non-nil, is the effective policy snapshot for the
// connected user. It is scoped per-user: per-user policy settings
// are merged with device-wide settings, with device-wide taking
// precedence. Sent initially when [NotifySysPolicyChanges] is set,
// and on change thereafter.
Policy *policyclient.PolicySnapshot `json:",omitzero"`
// type is mirrored in xcode/IPN/Core/LocalAPI/Model/LocalAPIModel.swift // type is mirrored in xcode/IPN/Core/LocalAPI/Model/LocalAPIModel.swift
} }
+2 -1
View File
@@ -247,5 +247,6 @@ func isNotableNotify(n *ipn.Notify) bool {
len(n.IncomingFiles) > 0 || len(n.IncomingFiles) > 0 ||
len(n.OutgoingFiles) > 0 || len(n.OutgoingFiles) > 0 ||
n.FilesWaiting != nil || n.FilesWaiting != nil ||
n.SuggestedExitNode != nil n.SuggestedExitNode != nil ||
n.Policy != nil
} }
+45 -3
View File
@@ -2330,8 +2330,8 @@ func (b *LocalBackend) applyExitNodeSysPolicyLocked(prefs *ipn.Prefs) (anyChange
// registerSysPolicyWatch subscribes to syspolicy change notifications // registerSysPolicyWatch subscribes to syspolicy change notifications
// and immediately applies the effective syspolicy settings to the current profile. // and immediately applies the effective syspolicy settings to the current profile.
func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) { func (b *LocalBackend) registerSysPolicyWatch() (unregister func(), err error) {
if unregister, err = b.polc.RegisterChangeCallback(b.sysPolicyChanged); err != nil { if unregister, err = b.polc.RegisterChangeCallback("", b.sysPolicyChanged); err != nil {
return nil, fmt.Errorf("syspolicy: LocalBacked failed to register policy change callback: %v", err) return nil, fmt.Errorf("syspolicy: LocalBackend failed to register policy change callback: %v", err)
} }
if prefs, anyChange := b.reconcilePrefs(); anyChange { if prefs, anyChange := b.reconcilePrefs(); anyChange {
b.logf("syspolicy: changed initial profile prefs: %v", prefs.Pretty()) b.logf("syspolicy: changed initial profile prefs: %v", prefs.Pretty())
@@ -2392,6 +2392,28 @@ func (b *LocalBackend) sysPolicyChanged(policy policyclient.PolicyChange) {
} }
} }
// sysPolicyChangedForSession is called when the effective policy for a session's
// user changes. It fetches the new snapshot and sends it to that session.
func (b *LocalBackend) sysPolicyChangedForSession(sess *watchSession) {
b.mu.Lock()
defer b.mu.Unlock()
snapshot, err := b.polc.GetPolicySnapshot("")
if err != nil || snapshot == nil {
return
}
n := ipn.Notify{Policy: snapshot, Version: version.Long()}
for _, f := range b.extHost.Hooks().MutateNotifyLocked {
f(&n)
}
nForSess := b.notifyForSessionLocked(sess, &n)
select {
case sess.ch <- nForSess:
default:
}
}
var ( var (
_ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil) _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
_ controlclient.PacketFilterUpdater = (*LocalBackend)(nil) _ controlclient.PacketFilterUpdater = (*LocalBackend)(nil)
@@ -3711,7 +3733,10 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
deadlockDone := b.CheckDeadlocks() deadlockDone := b.CheckDeadlocks()
b.mu.Lock() b.mu.Lock()
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion | ipn.NotifyPeerWireGuardState const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs |
ipn.NotifyInitialNetMap | ipn.NotifyInitialStatus |
ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode |
ipn.NotifyInitialClientVersion | ipn.NotifySysPolicyChanges | ipn.NotifyPeerWireGuardState
if mask&initialBits != 0 { if mask&initialBits != 0 {
cn := b.currentNode() cn := b.currentNode()
ini = &ipn.Notify{Version: version.Long()} ini = &ipn.Notify{Version: version.Long()}
@@ -3755,6 +3780,13 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
ini.ClientVersion = b.lastClientVersion ini.ClientVersion = b.lastClientVersion
} }
} }
if mask&ipn.NotifySysPolicyChanges != 0 {
var err error
ini.Policy, err = b.polc.GetPolicySnapshot("")
if err != nil {
b.logf("syspolicy: GetPolicySnapshot(\"\"): %v", err)
}
}
} }
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -3775,6 +3807,16 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
b.mu.Unlock() b.mu.Unlock()
deadlockDone() deadlockDone()
if mask&ipn.NotifySysPolicyChanges != 0 {
if unreg, err := b.polc.RegisterChangeCallback("", func(_ policyclient.PolicyChange) {
b.sysPolicyChangedForSession(session)
}); err == nil {
defer unreg()
} else {
b.logf("syspolicy: RegisterChangeCallback(\"\"): %v", err)
}
}
metricCurrentWatchIPNBus.Add(1) metricCurrentWatchIPNBus.Add(1)
defer metricCurrentWatchIPNBus.Add(-1) defer metricCurrentWatchIPNBus.Add(-1)
+150
View File
@@ -71,7 +71,10 @@ import (
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/util/syspolicy" "tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/pkey" "tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/util/syspolicy/policytest" "tailscale.com/util/syspolicy/policytest"
"tailscale.com/util/syspolicy/rsop"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/syspolicy/source" "tailscale.com/util/syspolicy/source"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/filter" "tailscale.com/wgengine/filter"
@@ -8481,6 +8484,153 @@ func toStrings[T ~string](in []T) []string {
return out return out
} }
func TestWatchNotificationsInitialPolicy(t *testing.T) {
setting.SetDefinitionsForTest(t,
setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue),
)
store := source.NewTestStore(t)
rsop.RegisterStoreForTest(t, "TestStore", setting.DeviceScope, store)
sys := tsd.NewSystem()
sys.PolicyClient.Set(testPolicyClient{})
lb := newTestLocalBackendWithSys(t, sys)
nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{})
nw.watch(ipn.NotifySysPolicyChanges, []wantedNotification{
wantPolicyNotify(),
})
nw.check()
nw2 := newNotificationWatcher(t, lb, &ipnauth.TestActor{})
nw2.watch(0, nil, unexpectedPolicy)
nw2.check()
}
func TestPolicyChangeNotifiesWatcher(t *testing.T) {
setting.SetDefinitionsForTest(t,
setting.NewDefinition(pkey.AdminConsoleVisibility, setting.UserSetting, setting.VisibilityValue),
)
store := source.NewTestStore(t)
rsop.RegisterStoreForTest(t, "TestStore", setting.DeviceScope, store)
sys := tsd.NewSystem()
sys.PolicyClient.Set(testPolicyClient{})
lb := newTestLocalBackendWithSys(t, sys)
if err := lb.Start(ipn.Options{}); err != nil {
t.Fatalf("Start: %v", err)
}
nw := newNotificationWatcher(t, lb, &ipnauth.TestActor{})
nw.watch(ipn.NotifySysPolicyChanges, []wantedNotification{
wantPolicyNotify(),
wantPolicyWithSetting(pkey.AdminConsoleVisibility, "hide"),
})
store.SetStrings(source.TestSettingOf(pkey.AdminConsoleVisibility, "hide"))
nw.check()
}
func TestPolicyNotifyPerUser(t *testing.T) {
store := source.NewTestStore(t)
rsop.RegisterStoreForTest(t, "TestStore", setting.DeviceScope, store)
sys := tsd.NewSystem()
sys.PolicyClient.Set(testPolicyClient{})
lb := newTestLocalBackendWithSys(t, sys)
actorA := &ipnauth.TestActor{UID: "S-1-5-21-1001"}
actorB := &ipnauth.TestActor{UID: "S-1-5-21-1002"}
nwA := newNotificationWatcher(t, lb, actorA)
nwA.watch(ipn.NotifySysPolicyChanges, []wantedNotification{
wantPolicyNotify(),
})
nwA.check()
nwB := newNotificationWatcher(t, lb, actorB)
nwB.watch(ipn.NotifySysPolicyChanges, []wantedNotification{
wantPolicyNotify(),
})
nwB.check()
nwNoPolicy := newNotificationWatcher(t, lb, actorA)
nwNoPolicy.watch(0, nil, unexpectedPolicy)
store.SetStrings(source.TestSettingOf(pkey.AdminConsoleVisibility, "hide"))
nwNoPolicy.check()
}
func wantPolicyNotify() wantedNotification {
return wantedNotification{
name: "Policy",
cond: func(_ testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
return n.Policy != nil
},
}
}
func wantPolicyWithSetting(key pkey.Key, value string) wantedNotification {
return wantedNotification{
name: fmt.Sprintf("Policy-%s=%s", key, value),
cond: func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
if n.Policy == nil {
return false
}
if n.Policy == nil {
return false
}
got := n.Policy.Get(key)
if got == nil {
return false
}
if fmt.Sprint(got) != value {
t.Errorf("Policy[%s] = %v (%T); want %q", key, got, got, value)
}
return true
},
}
}
func unexpectedPolicy(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
if n.Policy != nil {
t.Errorf("unexpected Policy notification")
return true
}
return false
}
type testPolicyClient struct {
policyclient.NoPolicyClient
}
func (testPolicyClient) GetPolicySnapshot(uid string) (*policyclient.PolicySnapshot, error) {
scope := setting.DefaultScope()
if uid != "" {
scope = setting.UserScopeOf(uid)
}
p, err := rsop.PolicyFor(scope)
if err != nil {
return nil, err
}
return p.Get(), nil
}
func (testPolicyClient) RegisterChangeCallback(uid string, cb func(policyclient.PolicyChange)) (func(), error) {
scope := setting.DefaultScope()
if uid != "" {
scope = setting.UserScopeOf(uid)
}
p, err := rsop.PolicyFor(scope)
if err != nil {
return func() {}, err
}
return p.RegisterChangeCallback(func(change policyclient.PolicyChange) {
cb(change)
}), nil
}
type textUpdate struct { type textUpdate struct {
Advertise []string Advertise []string
Unadvertise []string Unadvertise []string
+1 -1
View File
@@ -78,7 +78,7 @@ func NewOSConfigurator(logf logger.Logf, health *health.Tracker, bus *eventbus.B
} }
var err error var err error
if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback(ret.sysPolicyChanged); err != nil { if ret.unregisterPolicyChangeCb, err = polc.RegisterChangeCallback("", ret.sysPolicyChanged); err != nil {
logf("error registering policy change callback: %v", err) // non-fatal logf("error registering policy change callback: %v", err) // non-fatal
} }
@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_syspolicy
package policyclient
import "tailscale.com/util/syspolicy/setting"
// PolicySnapshot is an alias for [settings.Snapshot] unless syspolicy is omitted
// from the build.
type policySnapshot = setting.Snapshot
@@ -0,0 +1,9 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_syspolicy
package policyclient
// PolicySnapshot is a stub when syspolicy is omitted from the build.
type policySnapshot struct{}
+18 -4
View File
@@ -62,11 +62,21 @@ type Client interface {
HasAnyOf(keys ...pkey.Key) (bool, error) HasAnyOf(keys ...pkey.Key) (bool, error)
// RegisterChangeCallback registers a callback function that will be called // RegisterChangeCallback registers a callback function that will be called
// whenever a policy change is detected. It returns a function to unregister // whenever a policy change is detected. If uid is empty, the callback fires
// the callback and an error if the registration fails. // for device-scope policy changes. If uid is non-empty, it fires for changes
RegisterChangeCallback(cb func(PolicyChange)) (unregister func(), err error) // to that user's effective policy (device + user policies merged). It returns
// a function to unregister the callback and an error if the registration fails.
RegisterChangeCallback(uid string, cb func(PolicyChange)) (unregister func(), err error)
// GetPolicySnapshot returns the effective policy snapshot for the given user ID.
// If uid is empty, returns the default-scope policy. Returns nil, nil if policy
// snapshots are not supported.
GetPolicySnapshot(uid string) (*PolicySnapshot, error)
} }
// PolicySnapshot is an alias for [setting.Snapshot] unless syspolicy is omitted from the build.
type PolicySnapshot = policySnapshot
// Get returns a non-nil [Client] implementation as a function of the // Get returns a non-nil [Client] implementation as a function of the
// build tags. It returns a no-op implementation if the full syspolicy // build tags. It returns a no-op implementation if the full syspolicy
// package is omitted from the build, or in tests. // package is omitted from the build, or in tests.
@@ -140,6 +150,10 @@ func (NoPolicyClient) HasAnyOf(keys ...pkey.Key) (bool, error) {
func (NoPolicyClient) SetDebugLoggingEnabled(enabled bool) {} func (NoPolicyClient) SetDebugLoggingEnabled(enabled bool) {}
func (NoPolicyClient) RegisterChangeCallback(cb func(PolicyChange)) (unregister func(), err error) { func (NoPolicyClient) RegisterChangeCallback(uid string, cb func(PolicyChange)) (unregister func(), err error) {
return func() {}, nil return func() {}, nil
} }
func (NoPolicyClient) GetPolicySnapshot(uid string) (*PolicySnapshot, error) {
return nil, nil
}
+5 -1
View File
@@ -226,7 +226,7 @@ func (c Config) HasAnyOf(keys ...pkey.Key) (bool, error) {
return false, nil return false, nil
} }
func (c Config) RegisterChangeCallback(callback func(policyclient.PolicyChange)) (func(), error) { func (c Config) RegisterChangeCallback(uid string, callback func(policyclient.PolicyChange)) (func(), error) {
w, ok := c[watchersKey].(*watchers) w, ok := c[watchersKey].(*watchers)
if !ok { if !ok {
return func() {}, nil return func() {}, nil
@@ -242,3 +242,7 @@ func (c Config) RegisterChangeCallback(callback func(policyclient.PolicyChange))
} }
func (sp Config) SetDebugLoggingEnabled(enabled bool) {} func (sp Config) SetDebugLoggingEnabled(enabled bool) {}
func (c Config) GetPolicySnapshot(uid string) (*policyclient.PolicySnapshot, error) {
return nil, nil
}
+20 -4
View File
@@ -129,8 +129,12 @@ func getDuration(name pkey.Key, defaultValue time.Duration) (time.Duration, erro
// registerChangeCallback adds a function that will be called whenever the effective policy // registerChangeCallback adds a function that will be called whenever the effective policy
// for the default scope changes. The returned function can be used to unregister the callback. // for the default scope changes. The returned function can be used to unregister the callback.
func registerChangeCallback(cb rsop.PolicyChangeCallback) (unregister func(), err error) { func registerChangeCallback(uid string, cb rsop.PolicyChangeCallback) (unregister func(), err error) {
effective, err := rsop.PolicyFor(setting.DefaultScope()) scope := setting.DefaultScope()
if uid != "" {
scope = setting.UserScopeOf(uid)
}
effective, err := rsop.PolicyFor(scope)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -259,6 +263,18 @@ func (globalSyspolicy) HasAnyOf(keys ...pkey.Key) (bool, error) {
return hasAnyOf(keys...) return hasAnyOf(keys...)
} }
func (globalSyspolicy) RegisterChangeCallback(cb func(policyclient.PolicyChange)) (unregister func(), err error) { func (globalSyspolicy) RegisterChangeCallback(uid string, cb func(policyclient.PolicyChange)) (unregister func(), err error) {
return registerChangeCallback(cb) return registerChangeCallback(uid, cb)
}
func (globalSyspolicy) GetPolicySnapshot(uid string) (*policyclient.PolicySnapshot, error) {
scope := setting.DefaultScope()
if uid != "" {
scope = setting.UserScopeOf(uid)
}
p, err := rsop.PolicyFor(scope)
if err != nil {
return nil, err
}
return p.Get(), nil
} }