mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-23 03:17:43 +00:00
util/syspolicy/source: add package for reading policy settings from external stores
We add package defining interfaces for policy stores, enabling creation of policy sources and reading settings from them. It includes a Windows-specific PlatformPolicyStore for GP and MDM policies stored in the Registry, and an in-memory TestStore for testing purposes. We also include an internal package that tracks and reports policy usage metrics when a policy setting is read from a store. Initially, it will be used only on Windows and Android, as macOS, iOS, and tvOS report their own metrics. However, we plan to use it across all platforms eventually. Updates #12687 Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
394
util/syspolicy/source/policy_reader.go
Normal file
394
util/syspolicy/source/policy_reader.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/syspolicy/internal/loggerx"
|
||||
"tailscale.com/util/syspolicy/internal/metrics"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
)
|
||||
|
||||
// Reader reads all configured policy settings from a given [Store].
|
||||
// It registers a change callback with the [Store] and maintains the current version
|
||||
// of the [setting.Snapshot] by lazily re-reading policy settings from the [Store]
|
||||
// whenever a new settings snapshot is requested with [Reader.GetSettings].
|
||||
// It is safe for concurrent use.
|
||||
type Reader struct {
|
||||
store Store
|
||||
origin *setting.Origin
|
||||
settings []*setting.Definition
|
||||
unregisterChangeNotifier func()
|
||||
doneCh chan struct{} // closed when [Reader] is closed.
|
||||
|
||||
mu sync.Mutex
|
||||
closing bool
|
||||
upToDate bool
|
||||
lastPolicy *setting.Snapshot
|
||||
sessions set.HandleSet[*ReadingSession]
|
||||
}
|
||||
|
||||
// newReader returns a new [Reader] that reads policy settings from a given [Store].
|
||||
// The returned reader takes ownership of the store. If the store implements [io.Closer],
|
||||
// the returned reader will close the store when it is closed.
|
||||
func newReader(store Store, origin *setting.Origin) (*Reader, error) {
|
||||
settings, err := setting.Definitions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if expirable, ok := store.(Expirable); ok {
|
||||
select {
|
||||
case <-expirable.Done():
|
||||
return nil, ErrStoreClosed
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
reader := &Reader{store: store, origin: origin, settings: settings, doneCh: make(chan struct{})}
|
||||
if changeable, ok := store.(Changeable); ok {
|
||||
// We should subscribe to policy change notifications first before reading
|
||||
// the policy settings from the store. This way we won't miss any notifications.
|
||||
if reader.unregisterChangeNotifier, err = changeable.RegisterChangeCallback(reader.onPolicyChange); err != nil {
|
||||
// Errors registering policy change callbacks are non-fatal.
|
||||
// TODO(nickkhyl): implement a background policy refresh every X minutes?
|
||||
loggerx.Errorf("failed to register %v policy change callback: %v", origin, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := reader.reload(true); err != nil {
|
||||
if reader.unregisterChangeNotifier != nil {
|
||||
reader.unregisterChangeNotifier()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if expirable, ok := store.(Expirable); ok {
|
||||
if waitCh := expirable.Done(); waitCh != nil {
|
||||
go func() {
|
||||
select {
|
||||
case <-waitCh:
|
||||
reader.Close()
|
||||
case <-reader.doneCh:
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// GetSettings returns the current [*setting.Snapshot],
|
||||
// re-reading it from from the underlying [Store] only if the policy
|
||||
// has changed since it was read last. It never fails and returns
|
||||
// the previous version of the policy settings if a read attempt fails.
|
||||
func (r *Reader) GetSettings() *setting.Snapshot {
|
||||
r.mu.Lock()
|
||||
upToDate, lastPolicy := r.upToDate, r.lastPolicy
|
||||
r.mu.Unlock()
|
||||
if upToDate {
|
||||
return lastPolicy
|
||||
}
|
||||
|
||||
policy, err := r.reload(false)
|
||||
if err != nil {
|
||||
// If the policy fails to reload completely, log an error and return the last cached version.
|
||||
// However, errors related to individual policy items are always
|
||||
// propagated to callers when they fetch those settings.
|
||||
loggerx.Errorf("failed to reload %v policy: %v", r.origin, err)
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
// ReadSettings reads policy settings from the underlying [Store] even if no
|
||||
// changes were detected. It returns the new [*setting.Snapshot],nil on
|
||||
// success or an undefined snapshot (possibly `nil`) along with a non-`nil`
|
||||
// error in case of failure.
|
||||
func (r *Reader) ReadSettings() (*setting.Snapshot, error) {
|
||||
return r.reload(true)
|
||||
}
|
||||
|
||||
// reload is like [Reader.ReadSettings], but allows specifying whether to re-read
|
||||
// an unchanged policy, and returns the last [*setting.Snapshot] if the read fails.
|
||||
func (r *Reader) reload(force bool) (*setting.Snapshot, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.upToDate && !force {
|
||||
return r.lastPolicy, nil
|
||||
}
|
||||
|
||||
if lockable, ok := r.store.(Lockable); ok {
|
||||
if err := lockable.Lock(); err != nil {
|
||||
return r.lastPolicy, err
|
||||
}
|
||||
defer lockable.Unlock()
|
||||
}
|
||||
|
||||
r.upToDate = true
|
||||
|
||||
metrics.Reset(r.origin)
|
||||
|
||||
var m map[setting.Key]setting.RawItem
|
||||
if lastPolicyCount := r.lastPolicy.Len(); lastPolicyCount > 0 {
|
||||
m = make(map[setting.Key]setting.RawItem, lastPolicyCount)
|
||||
}
|
||||
for _, s := range r.settings {
|
||||
if !r.origin.Scope().IsConfigurableSetting(s) {
|
||||
// Skip settings that cannot be configured in the current scope.
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := readPolicySettingValue(r.store, s)
|
||||
if err != nil && (errors.Is(err, setting.ErrNoSuchKey) || errors.Is(err, setting.ErrNotConfigured)) {
|
||||
metrics.ReportNotConfigured(r.origin, s)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
metrics.ReportConfigured(r.origin, s, val)
|
||||
} else {
|
||||
metrics.ReportError(r.origin, s, err)
|
||||
}
|
||||
|
||||
// If there's an error reading a single policy, such as a value type mismatch,
|
||||
// we'll wrap the error to preserve its text and return it
|
||||
// whenever someone attempts to fetch the value.
|
||||
// Otherwise, the errorText will be nil.
|
||||
errorText := setting.MaybeErrorText(err)
|
||||
item := setting.RawItemWith(val, errorText, r.origin)
|
||||
mak.Set(&m, s.Key(), item)
|
||||
}
|
||||
|
||||
newPolicy := setting.NewSnapshot(m, setting.SummaryWith(r.origin))
|
||||
if r.lastPolicy == nil || !newPolicy.EqualItems(r.lastPolicy) {
|
||||
r.lastPolicy = newPolicy
|
||||
}
|
||||
return r.lastPolicy, nil
|
||||
}
|
||||
|
||||
// ReadingSession is like [Reader], but with a channel that's written
|
||||
// to when there's a policy change, and closed when the session is terminated.
|
||||
type ReadingSession struct {
|
||||
reader *Reader
|
||||
policyChangedCh chan struct{} // 1-buffered channel
|
||||
handle set.Handle // in the reader.sessions
|
||||
closeInternal func()
|
||||
}
|
||||
|
||||
// OpenSession opens and returns a new session to r, allowing the caller
|
||||
// to get notified whenever a policy change is reported by the [source.Store],
|
||||
// or an [ErrStoreClosed] if the reader has already been closed.
|
||||
func (r *Reader) OpenSession() (*ReadingSession, error) {
|
||||
session := &ReadingSession{
|
||||
reader: r,
|
||||
policyChangedCh: make(chan struct{}, 1),
|
||||
}
|
||||
session.closeInternal = sync.OnceFunc(func() { close(session.policyChangedCh) })
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closing {
|
||||
return nil, ErrStoreClosed
|
||||
}
|
||||
session.handle = r.sessions.Add(session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetSettings is like [Reader.GetSettings].
|
||||
func (s *ReadingSession) GetSettings() *setting.Snapshot {
|
||||
return s.reader.GetSettings()
|
||||
}
|
||||
|
||||
// ReadSettings is like [Reader.ReadSettings].
|
||||
func (s *ReadingSession) ReadSettings() (*setting.Snapshot, error) {
|
||||
return s.reader.ReadSettings()
|
||||
}
|
||||
|
||||
// PolicyChanged returns a channel that's written to when
|
||||
// there's a policy change, closed when the session is terminated.
|
||||
func (s *ReadingSession) PolicyChanged() <-chan struct{} {
|
||||
return s.policyChangedCh
|
||||
}
|
||||
|
||||
// Close unregisters this session with the [Reader].
|
||||
func (s *ReadingSession) Close() {
|
||||
s.reader.mu.Lock()
|
||||
delete(s.reader.sessions, s.handle)
|
||||
s.closeInternal()
|
||||
s.reader.mu.Unlock()
|
||||
}
|
||||
|
||||
// onPolicyChange handles a policy change notification from the [Store],
|
||||
// invalidating the current [setting.Snapshot] in r,
|
||||
// and notifying the active [ReadingSession]s.
|
||||
func (r *Reader) onPolicyChange() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.upToDate = false
|
||||
for _, s := range r.sessions {
|
||||
select {
|
||||
case s.policyChangedCh <- struct{}{}:
|
||||
// Notified.
|
||||
default:
|
||||
// 1-buffered channel is full, meaning that another policy change
|
||||
// notification is already en route.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the store reader and the underlying store.
|
||||
func (r *Reader) Close() error {
|
||||
r.mu.Lock()
|
||||
if r.closing {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
r.closing = true
|
||||
r.mu.Unlock()
|
||||
|
||||
if r.unregisterChangeNotifier != nil {
|
||||
r.unregisterChangeNotifier()
|
||||
r.unregisterChangeNotifier = nil
|
||||
}
|
||||
|
||||
if closer, ok := r.store.(io.Closer); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.store = nil
|
||||
|
||||
close(r.doneCh)
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, c := range r.sessions {
|
||||
c.closeInternal()
|
||||
}
|
||||
r.sessions = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the reader is closed.
|
||||
func (r *Reader) Done() <-chan struct{} {
|
||||
return r.doneCh
|
||||
}
|
||||
|
||||
// ReadableSource is a [Source] open for reading.
|
||||
type ReadableSource struct {
|
||||
*Source
|
||||
*ReadingSession
|
||||
}
|
||||
|
||||
// Close closes the underlying [ReadingSession].
|
||||
func (s ReadableSource) Close() {
|
||||
s.ReadingSession.Close()
|
||||
}
|
||||
|
||||
// ReadableSources is a slice of [ReadableSource].
|
||||
type ReadableSources []ReadableSource
|
||||
|
||||
// Contains reports whether s contains the specified source.
|
||||
func (s ReadableSources) Contains(source *Source) bool {
|
||||
return s.IndexOf(source) != -1
|
||||
}
|
||||
|
||||
// IndexOf returns position of the specified source in s, or -1
|
||||
// if the source does not exist.
|
||||
func (s ReadableSources) IndexOf(source *Source) int {
|
||||
return slices.IndexFunc(s, func(rs ReadableSource) bool {
|
||||
return rs.Source == source
|
||||
})
|
||||
}
|
||||
|
||||
// InsertionIndexOf returns the position at which source can be inserted
|
||||
// to maintain the sorted order of the readableSources.
|
||||
// The return value is unspecified if s is not sorted on entry to InsertionIndexOf.
|
||||
func (s ReadableSources) InsertionIndexOf(source *Source) int {
|
||||
// Insert new sources after any existing sources with the same precedence,
|
||||
// and just before the first source with higher precedence.
|
||||
// Just like stable sort, but for insertion.
|
||||
// It's okay to use linear search as insertions are rare
|
||||
// and we never have more than just a few policy sources.
|
||||
higherPrecedence := func(rs ReadableSource) bool { return rs.Compare(source) > 0 }
|
||||
if i := slices.IndexFunc(s, higherPrecedence); i != -1 {
|
||||
return i
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// StableSort sorts [ReadableSource] in s by precedence, so that policy
|
||||
// settings from sources with higher precedence (e.g., [DeviceScope])
|
||||
// will be read and merged last, overriding any policy settings with
|
||||
// the same keys configured in sources with lower precedence
|
||||
// (e.g., [CurrentUserScope]).
|
||||
func (s *ReadableSources) StableSort() {
|
||||
sort.SliceStable(*s, func(i, j int) bool {
|
||||
return (*s)[i].Source.Compare((*s)[j].Source) < 0
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAt closes and deletes the i-th source from s.
|
||||
func (s *ReadableSources) DeleteAt(i int) {
|
||||
(*s)[i].Close()
|
||||
*s = slices.Delete(*s, i, i+1)
|
||||
}
|
||||
|
||||
// Close closes and deletes all sources in s.
|
||||
func (s *ReadableSources) Close() {
|
||||
for _, s := range *s {
|
||||
s.Close()
|
||||
}
|
||||
*s = nil
|
||||
}
|
||||
|
||||
func readPolicySettingValue(store Store, s *setting.Definition) (value any, err error) {
|
||||
switch key := s.Key(); s.Type() {
|
||||
case setting.BooleanValue:
|
||||
return store.ReadBoolean(key)
|
||||
case setting.IntegerValue:
|
||||
return store.ReadUInt64(key)
|
||||
case setting.StringValue:
|
||||
return store.ReadString(key)
|
||||
case setting.StringListValue:
|
||||
return store.ReadStringArray(key)
|
||||
case setting.PreferenceOptionValue:
|
||||
s, err := store.ReadString(key)
|
||||
if err == nil {
|
||||
var value setting.PreferenceOption
|
||||
if err = value.UnmarshalText([]byte(s)); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return setting.ShowChoiceByPolicy, err
|
||||
case setting.VisibilityValue:
|
||||
s, err := store.ReadString(key)
|
||||
if err == nil {
|
||||
var value setting.Visibility
|
||||
if err = value.UnmarshalText([]byte(s)); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return setting.VisibleByPolicy, err
|
||||
case setting.DurationValue:
|
||||
s, err := store.ReadString(key)
|
||||
if err == nil {
|
||||
var value time.Duration
|
||||
if value, err = time.ParseDuration(s); err == nil {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported setting type: %v", setting.ErrTypeMismatch, s.Type())
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user