util/syspolicy/setting: add package that contains types for the next syspolicy PRs

Package setting contains types for defining and representing policy settings.
It facilitates the registration of setting definitions using Register and RegisterDefinition,
and the retrieval of registered setting definitions via Definitions and DefinitionOf.
This package is intended for use primarily within the syspolicy package hierarchy,
and added in a preparation for the next PRs.

Updates #12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2024-08-03 20:41:10 -05:00 committed by Nick Khyl
parent a61825c7b8
commit 67df9abdc6
20 changed files with 2623 additions and 90 deletions

View File

@ -10,7 +10,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
@ -146,9 +146,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/ctxkey from tailscale.com/tsweb+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/fastuuid from tailscale.com/tsweb
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
@ -159,6 +161,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
@ -180,6 +184,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
W golang.org/x/exp/constraints from tailscale.com/util/winutil
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http

View File

@ -96,7 +96,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 github.com/fsnotify/fsnotify from sigs.k8s.io/controller-runtime/pkg/certwatcher
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/ipset+
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
@ -804,6 +804,8 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/syspolicy from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/control/controlclient+

View File

@ -9,7 +9,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
@ -152,9 +152,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+
tailscale.com/util/ctxkey from tailscale.com/types/logger
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/groupmember from tailscale.com/client/web
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale+
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
@ -167,6 +169,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/singleflight from tailscale.com/net/dnscache+
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
tailscale.com/util/vizerror from tailscale.com/tailcfg+
@ -191,7 +195,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
W golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli
golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http+

View File

@ -90,7 +90,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 github.com/djherbis/times from tailscale.com/drive/driveimpl
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/tstun+
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
@ -396,6 +396,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+

View File

@ -0,0 +1,63 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package internal contains miscellaneous functions and types
// that are internal to the syspolicy packages.
package internal
import (
"bytes"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/types/lazy"
"tailscale.com/version"
)
// OSForTesting is the operating system override used for testing.
// It follows the same naming convention as [version.OS].
var OSForTesting lazy.SyncValue[string]
// OS is like [version.OS], but supports a test hook.
func OS() string {
return OSForTesting.Get(version.OS)
}
// TB is a subset of testing.TB that we use to set up test helpers.
// It's defined here to avoid pulling in the testing package.
type TB interface {
Helper()
Cleanup(func())
Logf(format string, args ...any)
Error(args ...any)
Errorf(format string, args ...any)
Fatal(args ...any)
Fatalf(format string, args ...any)
}
// EqualJSONForTest compares the JSON in j1 and j2 for semantic equality.
// It returns "", "", true if j1 and j2 are equal. Otherwise, it returns
// indented versions of j1 and j2 and false.
func EqualJSONForTest(tb TB, j1, j2 jsontext.Value) (s1, s2 string, equal bool) {
tb.Helper()
j1 = j1.Clone()
j2 = j2.Clone()
// Canonicalize JSON values for comparison.
if err := j1.Canonicalize(); err != nil {
tb.Error(err)
}
if err := j2.Canonicalize(); err != nil {
tb.Error(err)
}
// Check and return true if the two values are structurally equal.
if bytes.Equal(j1, j2) {
return "", "", true
}
// Otherwise, format the values for display and return false.
if err := j1.Indent("", "\t"); err != nil {
tb.Fatal(err)
}
if err := j2.Indent("", "\t"); err != nil {
tb.Fatal(err)
}
return j1.String(), j2.String(), false
}

View File

@ -3,7 +3,9 @@
package syspolicy
type Key string
import "tailscale.com/util/syspolicy/setting"
type Key = setting.Key
const (
// Keys with a string value

View File

@ -0,0 +1,71 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"errors"
"tailscale.com/types/ptr"
)
var (
// ErrNotConfigured is returned when the requested policy setting is not configured.
ErrNotConfigured = errors.New("not configured")
// ErrTypeMismatch is returned when there's a type mismatch between the actual type
// of the setting value and the expected type.
ErrTypeMismatch = errors.New("type mismatch")
// ErrNoSuchKey is returned by [DefinitionOf] when no policy setting
// has been registered with the specified key.
//
// Until 2024-08-02, this error was also returned by a [Handler] when the specified
// key did not have a value set. While the package maintains compatibility with this
// usage of ErrNoSuchKey, it is recommended to return [ErrNotConfigured] from newer
// [source.Store] implementations.
ErrNoSuchKey = errors.New("no such key")
)
// ErrorText represents an error that occurs when reading or parsing a policy setting.
// This includes errors due to permissions issues, value type and format mismatches,
// and other platform- or source-specific errors. It does not include
// [ErrNotConfigured] and [ErrNoSuchKey], as those correspond to unconfigured
// policy settings rather than settings that cannot be read or parsed
// due to an error.
//
// ErrorText is used to marshal errors when a policy setting is sent over the wire,
// allowing the error to be logged or displayed. It does not preserve the
// type information of the underlying error.
type ErrorText string
// NewErrorText returns a [ErrorText] with the specified error message.
func NewErrorText(text string) *ErrorText {
return ptr.To(ErrorText(text))
}
// NewErrorTextFromError returns an [ErrorText] with the text of the specified error,
// or nil if err is nil, [ErrNotConfigured], or [ErrNoSuchKey].
func NewErrorTextFromError(err error) *ErrorText {
if err == nil || errors.Is(err, ErrNotConfigured) || errors.Is(err, ErrNoSuchKey) {
return nil
}
if err, ok := err.(*ErrorText); ok {
return err
}
return ptr.To(ErrorText(err.Error()))
}
// Error implements error.
func (e ErrorText) Error() string {
return string(e)
}
// MarshalText implements [encoding.TextMarshaler].
func (e ErrorText) MarshalText() (text []byte, err error) {
return []byte(e.Error()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
func (e *ErrorText) UnmarshalText(text []byte) error {
*e = ErrorText(text)
return nil
}

View File

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
// Key is a string that uniquely identifies a policy and must remain unchanged
// once established and documented for a given policy setting. It may contain
// alphanumeric characters and zero or more [KeyPathSeparator]s to group
// individual policy settings into categories.
type Key string
// KeyPathSeparator allows logical grouping of policy settings into categories.
const KeyPathSeparator = "/"

View File

@ -0,0 +1,71 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
// Origin describes where a policy or a policy setting is configured.
type Origin struct {
data settingOrigin
}
// settingOrigin is the marshallable data of an [Origin].
type settingOrigin struct {
Name string `json:",omitzero"`
Scope PolicyScope
}
// NewOrigin returns a new [Origin] with the specified scope.
func NewOrigin(scope PolicyScope) *Origin {
return NewNamedOrigin("", scope)
}
// NewNamedOrigin returns a new [Origin] with the specified scope and name.
func NewNamedOrigin(name string, scope PolicyScope) *Origin {
return &Origin{settingOrigin{name, scope}}
}
// Scope reports the policy [PolicyScope] where the setting is configured.
func (s Origin) Scope() PolicyScope {
return s.data.Scope
}
// Name returns the name of the policy source where the setting is configured,
// or "" if not available.
func (s Origin) Name() string {
return s.data.Name
}
// String implements [fmt.Stringer].
func (s Origin) String() string {
if s.Name() != "" {
return fmt.Sprintf("%s (%v)", s.Name(), s.Scope())
}
return s.Scope().String()
}
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
func (s Origin) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
return jsonv2.MarshalEncode(out, &s.data, opts)
}
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
func (s *Origin) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
return jsonv2.UnmarshalDecode(in, &s.data, opts)
}
// MarshalJSON implements [json.Marshaler].
func (s Origin) MarshalJSON() ([]byte, error) {
return jsonv2.Marshal(s) // uses MarshalJSONV2
}
// UnmarshalJSON implements [json.Unmarshaler].
func (s *Origin) UnmarshalJSON(b []byte) error {
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
}

View File

@ -0,0 +1,189 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"fmt"
"strings"
"tailscale.com/types/lazy"
)
var (
lazyDefaultScope lazy.SyncValue[PolicyScope]
// DeviceScope indicates a scope containing device-global policies.
DeviceScope = PolicyScope{kind: DeviceSetting}
// CurrentProfileScope indicates a scope containing policies that apply to the
// currently active Tailscale profile.
CurrentProfileScope = PolicyScope{kind: ProfileSetting}
// CurrentUserScope indicates a scope containing policies that apply to the
// current user, for whatever that means on the current platform and
// in the current application context.
CurrentUserScope = PolicyScope{kind: UserSetting}
)
// PolicyScope is a management scope.
type PolicyScope struct {
kind Scope
userID string
profileID string
}
// DefaultScope returns the default [PolicyScope] to be used by a program
// when querying policy settings.
// It returns [DeviceScope], unless explicitly changed with [SetDefaultScope].
func DefaultScope() PolicyScope {
return lazyDefaultScope.Get(func() PolicyScope { return DeviceScope })
}
// SetDefaultScope attempts to set the specified scope as the default scope
// to be used by a program when querying policy settings.
// It fails and returns false if called more than once, or if the [DefaultScope]
// has already been used.
func SetDefaultScope(scope PolicyScope) bool {
return lazyDefaultScope.Set(scope)
}
// UserScopeOf returns a policy [PolicyScope] of the user with the specified id.
func UserScopeOf(uid string) PolicyScope {
return PolicyScope{kind: UserSetting, userID: uid}
}
// Kind reports the scope kind of s.
func (s PolicyScope) Kind() Scope {
return s.kind
}
// IsApplicableSetting reports whether the specified setting applies to
// and can be retrieved for this scope. Policy settings are applicable
// to their own scopes as well as more specific scopes. For example,
// device settings are applicable to device, profile and user scopes,
// but user settings are only applicable to user scopes.
// For instance, a menu visibility setting is inherently a user setting
// and only makes sense in the context of a specific user.
func (s PolicyScope) IsApplicableSetting(setting *Definition) bool {
return setting != nil && setting.Scope() <= s.Kind()
}
// IsConfigurableSetting reports whether the specified setting can be configured
// by a policy at this scope. Policy settings are configurable at their own scopes
// as well as broader scopes. For example, [UserSetting]s are configurable in
// user, profile, and device scopes, but [DeviceSetting]s are only configurable
// in the [DeviceScope]. For instance, the InstallUpdates policy setting
// can only be configured in the device scope, as it controls whether updates
// will be installed automatically on the device, rather than for specific users.
func (s PolicyScope) IsConfigurableSetting(setting *Definition) bool {
return setting != nil && setting.Scope() >= s.Kind()
}
// Contains reports whether policy settings that apply to s also apply to s2.
// For example, policy settings that apply to the [DeviceScope] also apply to
// the [CurrentUserScope].
func (s PolicyScope) Contains(s2 PolicyScope) bool {
if s.Kind() > s2.Kind() {
return false
}
switch s.Kind() {
case DeviceSetting:
return true
case ProfileSetting:
return s.profileID == s2.profileID
case UserSetting:
return s.userID == s2.userID
default:
panic("unreachable")
}
}
// StrictlyContains is like [PolicyScope.Contains], but returns false
// when s and s2 is the same scope.
func (s PolicyScope) StrictlyContains(s2 PolicyScope) bool {
return s != s2 && s.Contains(s2)
}
// String implements [fmt.Stringer].
func (s PolicyScope) String() string {
if s.profileID == "" && s.userID == "" {
return s.kind.String()
}
return s.stringSlow()
}
// MarshalText implements [encoding.TextMarshaler].
func (s PolicyScope) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
// MarshalText implements [encoding.TextUnmarshaler].
func (s *PolicyScope) UnmarshalText(b []byte) error {
*s = PolicyScope{}
parts := strings.SplitN(string(b), "/", 2)
for i, part := range parts {
kind, id, err := parseScopeAndID(part)
if err != nil {
return err
}
if i > 0 && kind <= s.kind {
return fmt.Errorf("invalid scope hierarchy: %s", b)
}
s.kind = kind
switch kind {
case DeviceSetting:
if id != "" {
return fmt.Errorf("the device scope must not have an ID: %s", b)
}
case ProfileSetting:
s.profileID = id
case UserSetting:
s.userID = id
}
}
return nil
}
func (s PolicyScope) stringSlow() string {
var sb strings.Builder
writeScopeWithID := func(s Scope, id string) {
sb.WriteString(s.String())
if id != "" {
sb.WriteRune('(')
sb.WriteString(id)
sb.WriteRune(')')
}
}
if s.kind == ProfileSetting || s.profileID != "" {
writeScopeWithID(ProfileSetting, s.profileID)
if s.kind != ProfileSetting {
sb.WriteRune('/')
}
}
if s.kind == UserSetting {
writeScopeWithID(UserSetting, s.userID)
}
return sb.String()
}
func parseScopeAndID(s string) (scope Scope, id string, err error) {
name, params, ok := extractScopeAndParams(s)
if !ok {
return 0, "", fmt.Errorf("%q is not a valid scope string", s)
}
if err := scope.UnmarshalText([]byte(name)); err != nil {
return 0, "", err
}
return scope, params, nil
}
func extractScopeAndParams(s string) (name, params string, ok bool) {
paramsStart := strings.Index(s, "(")
if paramsStart == -1 {
return s, "", true
}
paramsEnd := strings.LastIndex(s, ")")
if paramsEnd < paramsStart {
return "", "", false
}
return s[0:paramsStart], s[paramsStart+1 : paramsEnd], true
}

View File

@ -0,0 +1,565 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"reflect"
"testing"
jsonv2 "github.com/go-json-experiment/json"
)
func TestPolicyScopeIsApplicableSetting(t *testing.T) {
tests := []struct {
name string
scope PolicyScope
setting *Definition
wantApplicable bool
}{
{
name: "DeviceScope/DeviceSetting",
scope: DeviceScope,
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
wantApplicable: true,
},
{
name: "DeviceScope/ProfileSetting",
scope: DeviceScope,
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
wantApplicable: false,
},
{
name: "DeviceScope/UserSetting",
scope: DeviceScope,
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
wantApplicable: false,
},
{
name: "ProfileScope/DeviceSetting",
scope: CurrentProfileScope,
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
wantApplicable: true,
},
{
name: "ProfileScope/ProfileSetting",
scope: CurrentProfileScope,
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
wantApplicable: true,
},
{
name: "ProfileScope/UserSetting",
scope: CurrentProfileScope,
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
wantApplicable: false,
},
{
name: "UserScope/DeviceSetting",
scope: CurrentUserScope,
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
wantApplicable: true,
},
{
name: "UserScope/ProfileSetting",
scope: CurrentUserScope,
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
wantApplicable: true,
},
{
name: "UserScope/UserSetting",
scope: CurrentUserScope,
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
wantApplicable: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotApplicable := tt.scope.IsApplicableSetting(tt.setting)
if gotApplicable != tt.wantApplicable {
t.Fatalf("got %v, want %v", gotApplicable, tt.wantApplicable)
}
})
}
}
func TestPolicyScopeIsConfigurableSetting(t *testing.T) {
tests := []struct {
name string
scope PolicyScope
setting *Definition
wantConfigurable bool
}{
{
name: "DeviceScope/DeviceSetting",
scope: DeviceScope,
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
wantConfigurable: true,
},
{
name: "DeviceScope/ProfileSetting",
scope: DeviceScope,
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
wantConfigurable: true,
},
{
name: "DeviceScope/UserSetting",
scope: DeviceScope,
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
wantConfigurable: true,
},
{
name: "ProfileScope/DeviceSetting",
scope: CurrentProfileScope,
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
wantConfigurable: false,
},
{
name: "ProfileScope/ProfileSetting",
scope: CurrentProfileScope,
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
wantConfigurable: true,
},
{
name: "ProfileScope/UserSetting",
scope: CurrentProfileScope,
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
wantConfigurable: true,
},
{
name: "UserScope/DeviceSetting",
scope: CurrentUserScope,
setting: NewDefinition("TestSetting", DeviceSetting, IntegerValue),
wantConfigurable: false,
},
{
name: "UserScope/ProfileSetting",
scope: CurrentUserScope,
setting: NewDefinition("TestSetting", ProfileSetting, IntegerValue),
wantConfigurable: false,
},
{
name: "UserScope/UserSetting",
scope: CurrentUserScope,
setting: NewDefinition("TestSetting", UserSetting, IntegerValue),
wantConfigurable: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotConfigurable := tt.scope.IsConfigurableSetting(tt.setting)
if gotConfigurable != tt.wantConfigurable {
t.Fatalf("got %v, want %v", gotConfigurable, tt.wantConfigurable)
}
})
}
}
func TestPolicyScopeContains(t *testing.T) {
tests := []struct {
name string
scopeA PolicyScope
scopeB PolicyScope
wantAContainsB bool
wantAStrictlyContainsB bool
}{
{
name: "DeviceScope/DeviceScope",
scopeA: DeviceScope,
scopeB: DeviceScope,
wantAContainsB: true,
wantAStrictlyContainsB: false,
},
{
name: "DeviceScope/CurrentProfileScope",
scopeA: DeviceScope,
scopeB: CurrentProfileScope,
wantAContainsB: true,
wantAStrictlyContainsB: true,
},
{
name: "DeviceScope/UserScope",
scopeA: DeviceScope,
scopeB: CurrentUserScope,
wantAContainsB: true,
wantAStrictlyContainsB: true,
},
{
name: "ProfileScope/DeviceScope",
scopeA: CurrentProfileScope,
scopeB: DeviceScope,
wantAContainsB: false,
wantAStrictlyContainsB: false,
},
{
name: "ProfileScope/ProfileScope",
scopeA: CurrentProfileScope,
scopeB: CurrentProfileScope,
wantAContainsB: true,
wantAStrictlyContainsB: false,
},
{
name: "ProfileScope/UserScope",
scopeA: CurrentProfileScope,
scopeB: CurrentUserScope,
wantAContainsB: true,
wantAStrictlyContainsB: true,
},
{
name: "UserScope/DeviceScope",
scopeA: CurrentUserScope,
scopeB: DeviceScope,
wantAContainsB: false,
wantAStrictlyContainsB: false,
},
{
name: "UserScope/ProfileScope",
scopeA: CurrentUserScope,
scopeB: CurrentProfileScope,
wantAContainsB: false,
wantAStrictlyContainsB: false,
},
{
name: "UserScope/UserScope",
scopeA: CurrentUserScope,
scopeB: CurrentUserScope,
wantAContainsB: true,
wantAStrictlyContainsB: false,
},
{
name: "UserScope(1234)/UserScope(1234)",
scopeA: UserScopeOf("1234"),
scopeB: UserScopeOf("1234"),
wantAContainsB: true,
wantAStrictlyContainsB: false,
},
{
name: "UserScope(1234)/UserScope(5678)",
scopeA: UserScopeOf("1234"),
scopeB: UserScopeOf("5678"),
wantAContainsB: false,
wantAStrictlyContainsB: false,
},
{
name: "ProfileScope(A)/UserScope(A/1234)",
scopeA: PolicyScope{kind: ProfileSetting, profileID: "A"},
scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "A"},
wantAContainsB: true,
wantAStrictlyContainsB: true,
},
{
name: "ProfileScope(A)/UserScope(B/1234)",
scopeA: PolicyScope{kind: ProfileSetting, profileID: "A"},
scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "B"},
wantAContainsB: false,
wantAStrictlyContainsB: false,
},
{
name: "UserScope(1234)/UserScope(A/1234)",
scopeA: PolicyScope{kind: UserSetting, userID: "1234"},
scopeB: PolicyScope{kind: UserSetting, userID: "1234", profileID: "A"},
wantAContainsB: true,
wantAStrictlyContainsB: true,
},
{
name: "UserScope(1234)/UserScope(A/5678)",
scopeA: PolicyScope{kind: UserSetting, userID: "1234"},
scopeB: PolicyScope{kind: UserSetting, userID: "5678", profileID: "A"},
wantAContainsB: false,
wantAStrictlyContainsB: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotContains := tt.scopeA.Contains(tt.scopeB)
if gotContains != tt.wantAContainsB {
t.Fatalf("WithinOf: got %v, want %v", gotContains, tt.wantAContainsB)
}
gotStrictlyContains := tt.scopeA.StrictlyContains(tt.scopeB)
if gotStrictlyContains != tt.wantAStrictlyContainsB {
t.Fatalf("StrictlyWithinOf: got %v, want %v", gotStrictlyContains, tt.wantAStrictlyContainsB)
}
})
}
}
func TestPolicyScopeMarshalUnmarshal(t *testing.T) {
tests := []struct {
name string
in any
wantJSON string
wantError bool
}{
{
name: "null-scope",
in: &struct {
Scope PolicyScope
}{},
wantJSON: `{"Scope":"Device"}`,
},
{
name: "null-scope-omit-zero",
in: &struct {
Scope PolicyScope `json:",omitzero"`
}{},
wantJSON: `{}`,
},
{
name: "device-scope",
in: &struct {
Scope PolicyScope
}{DeviceScope},
wantJSON: `{"Scope":"Device"}`,
},
{
name: "current-profile-scope",
in: &struct {
Scope PolicyScope
}{CurrentProfileScope},
wantJSON: `{"Scope":"Profile"}`,
},
{
name: "current-user-scope",
in: &struct {
Scope PolicyScope
}{CurrentUserScope},
wantJSON: `{"Scope":"User"}`,
},
{
name: "specific-user-scope",
in: &struct {
Scope PolicyScope
}{UserScopeOf("_")},
wantJSON: `{"Scope":"User(_)"}`,
},
{
name: "specific-user-scope",
in: &struct {
Scope PolicyScope
}{UserScopeOf("S-1-5-21-3698941153-1525015703-2649197413-1001")},
wantJSON: `{"Scope":"User(S-1-5-21-3698941153-1525015703-2649197413-1001)"}`,
},
{
name: "specific-profile-scope",
in: &struct {
Scope PolicyScope
}{PolicyScope{kind: ProfileSetting, profileID: "1234"}},
wantJSON: `{"Scope":"Profile(1234)"}`,
},
{
name: "specific-profile-and-user-scope",
in: &struct {
Scope PolicyScope
}{PolicyScope{
kind: UserSetting,
profileID: "1234",
userID: "S-1-5-21-3698941153-1525015703-2649197413-1001",
}},
wantJSON: `{"Scope":"Profile(1234)/User(S-1-5-21-3698941153-1525015703-2649197413-1001)"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotJSON, err := jsonv2.Marshal(tt.in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(gotJSON) != tt.wantJSON {
t.Fatalf("Marshal got %s, want %s", gotJSON, tt.wantJSON)
}
wantBack := tt.in
gotBack := reflect.New(reflect.TypeOf(tt.in).Elem()).Interface()
err = jsonv2.Unmarshal(gotJSON, gotBack)
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !reflect.DeepEqual(gotBack, wantBack) {
t.Fatalf("Unmarshal got %+v, want %+v", gotBack, wantBack)
}
})
}
}
func TestPolicyScopeUnmarshalSpecial(t *testing.T) {
tests := []struct {
name string
json string
want any
wantError bool
}{
{
name: "empty",
json: "{}",
want: &struct {
Scope PolicyScope
}{},
},
{
name: "too-many-scopes",
json: `{"Scope":"Device/Profile/User"}`,
wantError: true,
},
{
name: "user/profile", // incorrect order
json: `{"Scope":"User/Profile"}`,
wantError: true,
},
{
name: "profile-user-no-params",
json: `{"Scope":"Profile/User"}`,
want: &struct {
Scope PolicyScope
}{CurrentUserScope},
},
{
name: "unknown-scope",
json: `{"Scope":"Unknown"}`,
wantError: true,
},
{
name: "unknown-scope/unknown-scope",
json: `{"Scope":"Unknown/Unknown"}`,
wantError: true,
},
{
name: "device-scope/unknown-scope",
json: `{"Scope":"Device/Unknown"}`,
wantError: true,
},
{
name: "unknown-scope/device-scope",
json: `{"Scope":"Unknown/Device"}`,
wantError: true,
},
{
name: "slash",
json: `{"Scope":"/"}`,
wantError: true,
},
{
name: "empty",
json: `{"Scope": ""`,
wantError: true,
},
{
name: "no-closing-bracket",
json: `{"Scope": "user(1234"`,
wantError: true,
},
{
name: "device-with-id",
json: `{"Scope": "device(123)"`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := &struct {
Scope PolicyScope
}{}
err := jsonv2.Unmarshal([]byte(tt.json), got)
if (err != nil) != tt.wantError {
t.Errorf("Marshal error: got %v, want %v", err, tt.wantError)
}
if err != nil {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("Unmarshal got %+v, want %+v", got, tt.want)
}
})
}
}
func TestExtractScopeAndParams(t *testing.T) {
tests := []struct {
name string
s string
scope string
params string
wantOk bool
}{
{
name: "empty",
s: "",
wantOk: true,
},
{
name: "scope-only",
s: "device",
scope: "device",
wantOk: true,
},
{
name: "scope-with-params",
s: "user(1234)",
scope: "user",
params: "1234",
wantOk: true,
},
{
name: "params-empty-scope",
s: "(1234)",
scope: "",
params: "1234",
wantOk: true,
},
{
name: "params-with-brackets",
s: "test()())))())",
scope: "test",
params: ")())))()",
wantOk: true,
},
{
name: "no-closing-bracket",
s: "user(1234",
scope: "",
params: "",
wantOk: false,
},
{
name: "open-before-close",
s: ")user(1234",
scope: "",
params: "",
wantOk: false,
},
{
name: "brackets-only",
s: ")(",
scope: "",
params: "",
wantOk: false,
},
{
name: "closing-bracket",
s: ")",
scope: "",
params: "",
wantOk: false,
},
{
name: "opening-bracket",
s: ")",
scope: "",
params: "",
wantOk: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scope, params, ok := extractScopeAndParams(tt.s)
if ok != tt.wantOk {
t.Logf("OK: got %v; want %v", ok, tt.wantOk)
}
if scope != tt.scope {
t.Logf("Scope: got %q; want %q", scope, tt.scope)
}
if params != tt.params {
t.Logf("Params: got %v; want %v", params, tt.params)
}
})
}
}

View File

@ -0,0 +1,67 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"fmt"
"tailscale.com/types/structs"
)
// RawItem contains a raw policy setting value as read from a policy store, or an
// error if the requested setting could not be read from the store. As a special
// case, it may also hold a value of the [Visibility], [PreferenceOption],
// or [time.Duration] types. While the policy store interface does not support
// these types natively, and the values of these types have to be unmarshalled
// or converted from strings, these setting types predate the typed policy
// hierarchies, and must be supported at this layer.
type RawItem struct {
_ structs.Incomparable
value any
err *ErrorText
origin *Origin // or nil
}
// RawItemOf returns a [RawItem] with the specified value.
func RawItemOf(value any) RawItem {
return RawItemWith(value, nil, nil)
}
// RawItemWith returns a [RawItem] with the specified value, error and origin.
func RawItemWith(value any, err *ErrorText, origin *Origin) RawItem {
return RawItem{value: value, err: err, origin: origin}
}
// Value returns the value of the policy setting, or nil if the policy setting
// is not configured, or an error occurred while reading it.
func (i RawItem) Value() any {
return i.value
}
// Error returns the error that occurred when reading the policy setting,
// or nil if no error occurred.
func (i RawItem) Error() error {
if i.err != nil {
return i.err
}
return nil
}
// Origin returns an optional [Origin] indicating where the policy setting is
// configured.
func (i RawItem) Origin() *Origin {
return i.origin
}
// String implements [fmt.Stringer].
func (i RawItem) String() string {
var suffix string
if i.origin != nil {
suffix = fmt.Sprintf(" - {%v}", i.origin)
}
if i.err != nil {
return fmt.Sprintf("Error{%q}%s", i.err.Error(), suffix)
}
return fmt.Sprintf("%v%s", i.value, suffix)
}

View File

@ -0,0 +1,348 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package setting contains types for defining and representing policy settings.
// It facilitates the registration of setting definitions using [Register] and [RegisterDefinition],
// and the retrieval of registered setting definitions via [Definitions] and [DefinitionOf].
// This package is intended for use primarily within the syspolicy package hierarchy.
package setting
import (
"fmt"
"slices"
"strings"
"sync"
"time"
"tailscale.com/types/lazy"
"tailscale.com/util/syspolicy/internal"
)
// Scope indicates the broadest scope at which a policy setting may apply,
// and the narrowest scope at which it may be configured.
type Scope int8
const (
// DeviceSetting indicates a policy setting that applies to a device, regardless of
// which OS user or Tailscale profile is currently active, if any.
// It can only be configured at a [DeviceScope].
DeviceSetting Scope = iota
// ProfileSetting indicates a policy setting that applies to a Tailscale profile.
// It can only be configured for a specific profile or at a [DeviceScope],
// in which case it applies to all profiles on the device.
ProfileSetting
// UserSetting indicates a policy setting that applies to users.
// It can be configured for a user, profile, or the entire device.
UserSetting
// NumScopes is the number of possible [Scope] values.
NumScopes int = iota // must be the last value in the const block.
)
// String implements [fmt.Stringer].
func (s Scope) String() string {
switch s {
case DeviceSetting:
return "Device"
case ProfileSetting:
return "Profile"
case UserSetting:
return "User"
default:
panic("unreachable")
}
}
// MarshalText implements [encoding.TextMarshaler].
func (s Scope) MarshalText() (text []byte, err error) {
return []byte(s.String()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
func (s *Scope) UnmarshalText(text []byte) error {
switch strings.ToLower(string(text)) {
case "device":
*s = DeviceSetting
case "profile":
*s = ProfileSetting
case "user":
*s = UserSetting
default:
return fmt.Errorf("%q is not a valid scope", string(text))
}
return nil
}
// Type is a policy setting value type.
// Except for [InvalidValue], which represents an invalid policy setting type,
// and [PreferenceOptionValue], [VisibilityValue], and [DurationValue],
// which have special handling due to their legacy status in the package,
// SettingTypes represent the raw value types readable from policy stores.
type Type int
const (
// InvalidValue indicates an invalid policy setting value type.
InvalidValue Type = iota
// BooleanValue indicates a policy setting whose underlying type is a bool.
BooleanValue
// IntegerValue indicates a policy setting whose underlying type is a uint64.
IntegerValue
// StringValue indicates a policy setting whose underlying type is a string.
StringValue
// StringListValue indicates a policy setting whose underlying type is a []string.
StringListValue
// PreferenceOptionValue indicates a three-state policy setting whose
// underlying type is a string, but the actual value is a [PreferenceOption].
PreferenceOptionValue
// VisibilityValue indicates a two-state boolean-like policy setting whose
// underlying type is a string, but the actual value is a [Visibility].
VisibilityValue
// DurationValue indicates an interval/period/duration policy setting whose
// underlying type is a string, but the actual value is a [time.Duration].
DurationValue
)
// String returns a string representation of t.
func (t Type) String() string {
switch t {
case InvalidValue:
return "Invalid"
case BooleanValue:
return "Boolean"
case IntegerValue:
return "Integer"
case StringValue:
return "String"
case StringListValue:
return "StringList"
case PreferenceOptionValue:
return "PreferenceOption"
case VisibilityValue:
return "Visibility"
case DurationValue:
return "Duration"
default:
panic("unreachable")
}
}
// ValueType is a constraint that allows Go types corresponding to [Type].
type ValueType interface {
bool | uint64 | string | []string | Visibility | PreferenceOption | time.Duration
}
// Definition defines policy key, scope and value type.
type Definition struct {
key Key
scope Scope
typ Type
platforms PlatformList
}
// NewDefinition returns a new [Definition] with the specified
// key, scope, type and supported platforms (see [PlatformList]).
func NewDefinition(k Key, s Scope, t Type, platforms ...string) *Definition {
return &Definition{key: k, scope: s, typ: t, platforms: platforms}
}
// Key returns a policy setting's identifier.
func (d *Definition) Key() Key {
if d == nil {
return ""
}
return d.key
}
// Scope reports the broadest [Scope] the policy setting may apply to.
func (d *Definition) Scope() Scope {
if d == nil {
return 0
}
return d.scope
}
// Type reports the underlying value type of the policy setting.
func (d *Definition) Type() Type {
if d == nil {
return InvalidValue
}
return d.typ
}
// IsSupported reports whether the policy setting is supported on the current OS.
func (d *Definition) IsSupported() bool {
if d == nil {
return false
}
return d.platforms.HasCurrent()
}
// SupportedPlatforms reports platforms on which the policy setting is supported.
// An empty [PlatformList] indicates that s is available on all platforms.
func (d *Definition) SupportedPlatforms() PlatformList {
if d == nil {
return nil
}
return d.platforms
}
// String implements [fmt.Stringer].
func (d *Definition) String() string {
if d == nil {
return "(nil)"
}
return fmt.Sprintf("%v(%q, %v)", d.scope, d.key, d.typ)
}
// Equal reports whether d and d2 have the same key, type and scope.
// It does not check whether both s and s2 are supported on the same platforms.
func (d *Definition) Equal(d2 *Definition) bool {
if d == d2 {
return true
}
if d == nil || d2 == nil {
return false
}
return d.key == d2.key && d.typ == d2.typ && d.scope == d2.scope
}
// DefinitionMap is a map of setting [Definition] by [Key].
type DefinitionMap map[Key]*Definition
var (
definitions lazy.SyncValue[DefinitionMap]
definitionsMu sync.Mutex
definitionsList []*Definition
definitionsUsed bool
)
// Register registers a policy setting with the specified key, scope, value type,
// and an optional list of supported platforms. All policy settings must be
// registered before any of them can be used. Register panics if called after
// invoking any functions that use the registered policy definitions. This
// includes calling [Definitions] or [DefinitionOf] directly, or reading any
// policy settings via syspolicy.
func Register(k Key, s Scope, t Type, platforms ...string) {
RegisterDefinition(NewDefinition(k, s, t, platforms...))
}
// RegisterDefinition is like [Register], but accepts a [Definition].
func RegisterDefinition(d *Definition) {
definitionsMu.Lock()
defer definitionsMu.Unlock()
registerLocked(d)
}
func registerLocked(d *Definition) {
if definitionsUsed {
panic("policy definitions are already in use")
}
definitionsList = append(definitionsList, d)
}
func settingDefinitions() (DefinitionMap, error) {
return definitions.GetErr(func() (DefinitionMap, error) {
definitionsMu.Lock()
defer definitionsMu.Unlock()
definitionsUsed = true
return DefinitionMapOf(definitionsList)
})
}
// DefinitionMapOf returns a [DefinitionMap] with the specified settings,
// or an error if any settings have the same key but different type or scope.
func DefinitionMapOf(settings []*Definition) (DefinitionMap, error) {
m := make(DefinitionMap, len(settings))
for _, s := range settings {
if existing, exists := m[s.key]; exists {
if existing.Equal(s) {
// Ignore duplicate setting definitions if they match. It is acceptable
// if the same policy setting was registered more than once
// (e.g. by the syspolicy package itself and by iOS/Android code).
existing.platforms.mergeFrom(s.platforms)
continue
}
return nil, fmt.Errorf("duplicate policy definition: %q", s.key)
}
m[s.key] = s
}
return m, nil
}
// SetDefinitionsForTest allows to register the specified setting definitions
// for the test duration. It is not concurrency-safe, but unlike [Register],
// it does not panic and can be called anytime.
// It returns an error if ds contains two different settings with the same [Key].
func SetDefinitionsForTest(tb lazy.TB, ds ...*Definition) error {
m, err := DefinitionMapOf(ds)
if err != nil {
return err
}
definitions.SetForTest(tb, m, err)
return nil
}
// DefinitionOf returns a setting definition by key,
// or [ErrNoSuchKey] if the specified key does not exist,
// or an error if there are conflicting policy definitions.
func DefinitionOf(k Key) (*Definition, error) {
ds, err := settingDefinitions()
if err != nil {
return nil, err
}
if d, ok := ds[k]; ok {
return d, nil
}
return nil, ErrNoSuchKey
}
// Definitions returns all registered setting definitions,
// or an error if different policies were registered under the same name.
func Definitions() ([]*Definition, error) {
ds, err := settingDefinitions()
if err != nil {
return nil, err
}
res := make([]*Definition, 0, len(ds))
for _, d := range ds {
res = append(res, d)
}
return res, nil
}
// PlatformList is a list of OSes.
// An empty list indicates that all possible platforms are supported.
type PlatformList []string
// Has reports whether l contains the target platform.
func (l PlatformList) Has(target string) bool {
if len(l) == 0 {
return true
}
return slices.ContainsFunc(l, func(os string) bool {
return strings.EqualFold(os, target)
})
}
// HasCurrent is like Has, but for the current platform.
func (l PlatformList) HasCurrent() bool {
return l.Has(internal.OS())
}
// mergeFrom merges l2 into l. Since an empty list indicates no platform restrictions,
// if either l or l2 is empty, the merged result in l will also be empty.
func (l *PlatformList) mergeFrom(l2 PlatformList) {
switch {
case len(*l) == 0:
// No-op. An empty list indicates no platform restrictions.
case len(l2) == 0:
// Merging with an empty list results in an empty list.
*l = l2
default:
// Append, sort and dedup.
*l = append(*l, l2...)
slices.Sort(*l)
*l = slices.Compact(*l)
}
}

View File

@ -0,0 +1,344 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"slices"
"strings"
"testing"
"tailscale.com/types/lazy"
"tailscale.com/types/ptr"
"tailscale.com/util/syspolicy/internal"
)
func TestSettingDefinition(t *testing.T) {
tests := []struct {
name string
setting *Definition
osOverride string
wantKey Key
wantScope Scope
wantType Type
wantIsSupported bool
wantSupportedPlatforms PlatformList
wantString string
}{
{
name: "Nil",
setting: nil,
wantKey: "",
wantScope: 0,
wantType: InvalidValue,
wantIsSupported: false,
wantString: "(nil)",
},
{
name: "Device/Invalid",
setting: NewDefinition("TestDevicePolicySetting", DeviceSetting, InvalidValue),
wantKey: "TestDevicePolicySetting",
wantScope: DeviceSetting,
wantType: InvalidValue,
wantIsSupported: true,
wantString: `Device("TestDevicePolicySetting", Invalid)`,
},
{
name: "Device/Integer",
setting: NewDefinition("TestDevicePolicySetting", DeviceSetting, IntegerValue),
wantKey: "TestDevicePolicySetting",
wantScope: DeviceSetting,
wantType: IntegerValue,
wantIsSupported: true,
wantString: `Device("TestDevicePolicySetting", Integer)`,
},
{
name: "Profile/String",
setting: NewDefinition("TestProfilePolicySetting", ProfileSetting, StringValue),
wantKey: "TestProfilePolicySetting",
wantScope: ProfileSetting,
wantType: StringValue,
wantIsSupported: true,
wantString: `Profile("TestProfilePolicySetting", String)`,
},
{
name: "Device/StringList",
setting: NewDefinition("AllowedSuggestedExitNodes", DeviceSetting, StringListValue),
wantKey: "AllowedSuggestedExitNodes",
wantScope: DeviceSetting,
wantType: StringListValue,
wantIsSupported: true,
wantString: `Device("AllowedSuggestedExitNodes", StringList)`,
},
{
name: "Device/PreferenceOption",
setting: NewDefinition("AdvertiseExitNode", DeviceSetting, PreferenceOptionValue),
wantKey: "AdvertiseExitNode",
wantScope: DeviceSetting,
wantType: PreferenceOptionValue,
wantIsSupported: true,
wantString: `Device("AdvertiseExitNode", PreferenceOption)`,
},
{
name: "User/Boolean",
setting: NewDefinition("TestUserPolicySetting", UserSetting, BooleanValue),
wantKey: "TestUserPolicySetting",
wantScope: UserSetting,
wantType: BooleanValue,
wantIsSupported: true,
wantString: `User("TestUserPolicySetting", Boolean)`,
},
{
name: "User/Visibility",
setting: NewDefinition("AdminConsole", UserSetting, VisibilityValue),
wantKey: "AdminConsole",
wantScope: UserSetting,
wantType: VisibilityValue,
wantIsSupported: true,
wantString: `User("AdminConsole", Visibility)`,
},
{
name: "User/Duration",
setting: NewDefinition("KeyExpirationNotice", UserSetting, DurationValue),
wantKey: "KeyExpirationNotice",
wantScope: UserSetting,
wantType: DurationValue,
wantIsSupported: true,
wantString: `User("KeyExpirationNotice", Duration)`,
},
{
name: "SupportedSetting",
setting: NewDefinition("DesktopPolicySetting", DeviceSetting, StringValue, "macos", "windows"),
osOverride: "windows",
wantKey: "DesktopPolicySetting",
wantScope: DeviceSetting,
wantType: StringValue,
wantIsSupported: true,
wantSupportedPlatforms: PlatformList{"macos", "windows"},
wantString: `Device("DesktopPolicySetting", String)`,
},
{
name: "UnsupportedSetting",
setting: NewDefinition("AndroidPolicySetting", DeviceSetting, StringValue, "android"),
osOverride: "macos",
wantKey: "AndroidPolicySetting",
wantScope: DeviceSetting,
wantType: StringValue,
wantIsSupported: false,
wantSupportedPlatforms: PlatformList{"android"},
wantString: `Device("AndroidPolicySetting", String)`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.osOverride != "" {
internal.OSForTesting.SetForTest(t, tt.osOverride, nil)
}
if !tt.setting.Equal(tt.setting) {
t.Errorf("the setting should be equal to itself")
}
if tt.setting != nil && !tt.setting.Equal(ptr.To(*tt.setting)) {
t.Errorf("the setting should be equal to its shallow copy")
}
if gotKey := tt.setting.Key(); gotKey != tt.wantKey {
t.Errorf("Key: got %q, want %q", gotKey, tt.wantKey)
}
if gotScope := tt.setting.Scope(); gotScope != tt.wantScope {
t.Errorf("Scope: got %v, want %v", gotScope, tt.wantScope)
}
if gotType := tt.setting.Type(); gotType != tt.wantType {
t.Errorf("Type: got %v, want %v", gotType, tt.wantType)
}
if gotIsSupported := tt.setting.IsSupported(); gotIsSupported != tt.wantIsSupported {
t.Errorf("IsSupported: got %v, want %v", gotIsSupported, tt.wantIsSupported)
}
if gotSupportedPlatforms := tt.setting.SupportedPlatforms(); !slices.Equal(gotSupportedPlatforms, tt.wantSupportedPlatforms) {
t.Errorf("SupportedPlatforms: got %v, want %v", gotSupportedPlatforms, tt.wantSupportedPlatforms)
}
if gotString := tt.setting.String(); gotString != tt.wantString {
t.Errorf("String: got %v, want %v", gotString, tt.wantString)
}
})
}
}
func TestRegisterSettingDefinition(t *testing.T) {
const testPolicySettingKey Key = "TestPolicySetting"
tests := []struct {
name string
key Key
wantEq *Definition
wantErr error
}{
{
name: "GetRegistered",
key: "TestPolicySetting",
wantEq: NewDefinition(testPolicySettingKey, DeviceSetting, StringValue),
},
{
name: "GetNonRegistered",
key: "OtherPolicySetting",
wantEq: nil,
wantErr: ErrNoSuchKey,
},
}
resetSettingDefinitions(t)
Register(testPolicySettingKey, DeviceSetting, StringValue)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotErr := DefinitionOf(tt.key)
if gotErr != tt.wantErr {
t.Errorf("gotErr %v, wantErr %v", gotErr, tt.wantErr)
}
if !got.Equal(tt.wantEq) {
t.Errorf("got %v, want %v", got, tt.wantEq)
}
})
}
}
func TestRegisterAfterUsePanics(t *testing.T) {
resetSettingDefinitions(t)
Register("TestPolicySetting", DeviceSetting, StringValue)
DefinitionOf("TestPolicySetting")
func() {
defer func() {
if gotPanic, wantPanic := recover(), "policy definitions are already in use"; gotPanic != wantPanic {
t.Errorf("gotPanic: %q, wantPanic: %q", gotPanic, wantPanic)
}
}()
Register("TestPolicySetting", DeviceSetting, StringValue)
}()
}
func TestRegisterDuplicateSettings(t *testing.T) {
tests := []struct {
name string
settings []*Definition
wantEq *Definition
wantErrStr string
}{
{
name: "NoConflict/Exact",
settings: []*Definition{
NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
},
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
},
{
name: "NoConflict/MergeOS-First",
settings: []*Definition{
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "android", "macos"),
NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
},
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
},
{
name: "NoConflict/MergeOS-Second",
settings: []*Definition{
NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "android", "macos"),
},
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue), // all platforms
},
{
name: "NoConflict/MergeOS-Both",
settings: []*Definition{
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "macos"),
NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "windows"),
},
wantEq: NewDefinition("TestPolicySetting", DeviceSetting, StringValue, "macos", "windows"),
},
{
name: "Conflict/Scope",
settings: []*Definition{
NewDefinition("TestPolicySetting", DeviceSetting, StringValue),
NewDefinition("TestPolicySetting", UserSetting, StringValue),
},
wantEq: nil,
wantErrStr: `duplicate policy definition: "TestPolicySetting"`,
},
{
name: "Conflict/Type",
settings: []*Definition{
NewDefinition("TestPolicySetting", UserSetting, StringValue),
NewDefinition("TestPolicySetting", UserSetting, IntegerValue),
},
wantEq: nil,
wantErrStr: `duplicate policy definition: "TestPolicySetting"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetSettingDefinitions(t)
for _, s := range tt.settings {
Register(s.Key(), s.Scope(), s.Type(), s.SupportedPlatforms()...)
}
got, err := DefinitionOf("TestPolicySetting")
var gotErrStr string
if err != nil {
gotErrStr = err.Error()
}
if gotErrStr != tt.wantErrStr {
t.Fatalf("ErrStr: got %q, want %q", gotErrStr, tt.wantErrStr)
}
if !got.Equal(tt.wantEq) {
t.Errorf("Definition got %v, want %v", got, tt.wantEq)
}
if !slices.Equal(got.SupportedPlatforms(), tt.wantEq.SupportedPlatforms()) {
t.Errorf("SupportedPlatforms got %v, want %v", got.SupportedPlatforms(), tt.wantEq.SupportedPlatforms())
}
})
}
}
func TestListSettingDefinitions(t *testing.T) {
definitions := []*Definition{
NewDefinition("TestDevicePolicySetting", DeviceSetting, IntegerValue),
NewDefinition("TestProfilePolicySetting", ProfileSetting, StringValue),
NewDefinition("TestUserPolicySetting", UserSetting, BooleanValue),
NewDefinition("TestStringListPolicySetting", DeviceSetting, StringListValue),
}
if err := SetDefinitionsForTest(t, definitions...); err != nil {
t.Fatalf("SetDefinitionsForTest failed: %v", err)
}
cmp := func(l, r *Definition) int {
return strings.Compare(string(l.Key()), string(r.Key()))
}
want := append([]*Definition{}, definitions...)
slices.SortFunc(want, cmp)
got, err := Definitions()
if err != nil {
t.Fatalf("Definitions failed: %v", err)
}
slices.SortFunc(got, cmp)
if !slices.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func resetSettingDefinitions(t *testing.T) {
t.Cleanup(func() {
definitionsMu.Lock()
definitionsList = nil
definitions = lazy.SyncValue[DefinitionMap]{}
definitionsUsed = false
definitionsMu.Unlock()
})
definitionsMu.Lock()
definitionsList = nil
definitions = lazy.SyncValue[DefinitionMap]{}
definitionsUsed = false
definitionsMu.Unlock()
}

View File

@ -0,0 +1,173 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"slices"
"strings"
xmaps "golang.org/x/exp/maps"
"tailscale.com/util/deephash"
)
// Snapshot is an immutable collection of ([Key], [RawItem]) pairs, representing
// a set of policy settings applied at a specific moment in time.
// A nil pointer to [Snapshot] is valid.
type Snapshot struct {
m map[Key]RawItem
sig deephash.Sum // of m
summary Summary
}
// NewSnapshot returns a new [Snapshot] with the specified items and options.
func NewSnapshot(items map[Key]RawItem, opts ...SummaryOption) *Snapshot {
return &Snapshot{m: xmaps.Clone(items), sig: deephash.Hash(&items), summary: SummaryWith(opts...)}
}
// All returns a map of all policy settings in s.
// The returned map must not be modified.
func (s *Snapshot) All() map[Key]RawItem {
if s == nil {
return nil
}
// TODO(nickkhyl): return iter.Seq2[[Key], [RawItem]] in Go 1.23,
// and remove [keyItemPair].
return s.m
}
// Get returns the value of the policy setting with the specified key
// or nil if it is not configured or has an error.
func (s *Snapshot) Get(k Key) any {
v, _ := s.GetErr(k)
return v
}
// GetErr returns the value of the policy setting with the specified key,
// [ErrNotConfigured] if it is not configured, or an error returned by
// the policy Store if the policy setting could not be read.
func (s *Snapshot) GetErr(k Key) (any, error) {
if s != nil {
if s, ok := s.m[k]; ok {
return s.Value(), s.Error()
}
}
return nil, ErrNotConfigured
}
// GetSetting returns the untyped policy setting with the specified key and true
// if a policy setting with such key has been configured;
// otherwise, it returns zero, false.
func (s *Snapshot) GetSetting(k Key) (setting RawItem, ok bool) {
setting, ok = s.m[k]
return setting, ok
}
// Equal reports whether s and s2 are equal.
func (s *Snapshot) Equal(s2 *Snapshot) bool {
if !s.EqualItems(s2) {
return false
}
return s.Summary() == s2.Summary()
}
// EqualItems reports whether items in s and s2 are equal.
func (s *Snapshot) EqualItems(s2 *Snapshot) bool {
if s == s2 {
return true
}
if s.Len() != s2.Len() {
return false
}
if s.Len() == 0 {
return true
}
return s.sig == s2.sig
}
// Keys return an iterator over keys in s. The iteration order is not specified
// and is not guaranteed to be the same from one call to the next.
func (s *Snapshot) Keys() []Key {
if s.m == nil {
return nil
}
// TODO(nickkhyl): return iter.Seq[Key] in Go 1.23.
return xmaps.Keys(s.m)
}
// Len reports the number of [RawItem]s in s.
func (s *Snapshot) Len() int {
if s == nil {
return 0
}
return len(s.m)
}
// Summary returns information about s as a whole rather than about specific [RawItem]s in it.
func (s *Snapshot) Summary() Summary {
if s == nil {
return Summary{}
}
return s.summary
}
// String implements [fmt.Stringer]
func (s *Snapshot) String() string {
if s.Len() == 0 && s.Summary().IsEmpty() {
return "{Empty}"
}
keys := s.Keys()
slices.Sort(keys)
var sb strings.Builder
if !s.summary.IsEmpty() {
sb.WriteRune('{')
if s.Len() == 0 {
sb.WriteString("Empty, ")
}
sb.WriteString(s.summary.String())
sb.WriteRune('}')
}
for _, k := range keys {
if sb.Len() != 0 {
sb.WriteRune('\n')
}
sb.WriteString(string(k))
sb.WriteString(" = ")
sb.WriteString(s.m[k].String())
}
return sb.String()
}
// MergeSnapshots returns a [Snapshot] that contains all [RawItem]s
// from snapshot1 and snapshot2 and the [Summary] with the narrower [PolicyScope].
// If there's a conflict between policy settings in the two snapshots,
// the policy settings from the snapshot with the broader scope take precedence.
// In other words, policy settings configured for the [DeviceScope] win
// over policy settings configured for a user scope.
func MergeSnapshots(snapshot1, snapshot2 *Snapshot) *Snapshot {
scope1, ok1 := snapshot1.Summary().Scope().GetOk()
scope2, ok2 := snapshot2.Summary().Scope().GetOk()
if ok1 && ok2 && scope1.StrictlyContains(scope2) {
// Swap snapshots if snapshot1 has higher precedence than snapshot2.
snapshot1, snapshot2 = snapshot2, snapshot1
}
if snapshot2.Len() == 0 {
return snapshot1
}
summaryOpts := make([]SummaryOption, 0, 2)
if scope, ok := snapshot1.Summary().Scope().GetOk(); ok {
// Use the scope from snapshot1, if present, which is the more specific snapshot.
summaryOpts = append(summaryOpts, scope)
}
if snapshot1.Len() == 0 {
if origin, ok := snapshot2.Summary().Origin().GetOk(); ok {
// Use the origin from snapshot2 if snapshot1 is empty.
summaryOpts = append(summaryOpts, origin)
}
return &Snapshot{snapshot2.m, snapshot2.sig, SummaryWith(summaryOpts...)}
}
m := make(map[Key]RawItem, snapshot1.Len()+snapshot2.Len())
xmaps.Copy(m, snapshot1.m)
xmaps.Copy(m, snapshot2.m) // snapshot2 has higher precedence
return &Snapshot{m, deephash.Hash(&m), SummaryWith(summaryOpts...)}
}

View File

@ -0,0 +1,435 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"testing"
"time"
)
func TestMergeSnapshots(t *testing.T) {
tests := []struct {
name string
s1, s2 *Snapshot
want *Snapshot
}{
{
name: "both-nil",
s1: nil,
s2: nil,
want: NewSnapshot(map[Key]RawItem{}),
},
{
name: "both-empty",
s1: NewSnapshot(map[Key]RawItem{}),
s2: NewSnapshot(map[Key]RawItem{}),
want: NewSnapshot(map[Key]RawItem{}),
},
{
name: "first-nil",
s1: nil,
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}),
},
{
name: "first-empty",
s1: NewSnapshot(map[Key]RawItem{}),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
},
{
name: "second-nil",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}),
s2: nil,
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}),
},
{
name: "second-empty",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
s2: NewSnapshot(map[Key]RawItem{}),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
},
{
name: "no-conflicts",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
s2: NewSnapshot(map[Key]RawItem{
"Setting4": {value: 2 * time.Hour},
"Setting5": {value: VisibleByPolicy},
"Setting6": {value: ShowChoiceByPolicy},
}),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
"Setting4": {value: 2 * time.Hour},
"Setting5": {value: VisibleByPolicy},
"Setting6": {value: ShowChoiceByPolicy},
}),
},
{
name: "with-conflicts",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 456},
"Setting3": {value: false},
"Setting4": {value: 2 * time.Hour},
}),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 456},
"Setting2": {value: "String"},
"Setting3": {value: false},
"Setting4": {value: 2 * time.Hour},
}),
},
{
name: "with-scope-first-wins",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}, DeviceScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 456},
"Setting3": {value: false},
"Setting4": {value: 2 * time.Hour},
}, CurrentUserScope),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
"Setting4": {value: 2 * time.Hour},
}, CurrentUserScope),
},
{
name: "with-scope-second-wins",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}, CurrentUserScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 456},
"Setting3": {value: false},
"Setting4": {value: 2 * time.Hour},
}, DeviceScope),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 456},
"Setting2": {value: "String"},
"Setting3": {value: false},
"Setting4": {value: 2 * time.Hour},
}, CurrentUserScope),
},
{
name: "with-scope-both-empty",
s1: NewSnapshot(map[Key]RawItem{}, CurrentUserScope),
s2: NewSnapshot(map[Key]RawItem{}, DeviceScope),
want: NewSnapshot(map[Key]RawItem{}, CurrentUserScope),
},
{
name: "with-scope-first-empty",
s1: NewSnapshot(map[Key]RawItem{}, CurrentUserScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true}},
DeviceScope, NewNamedOrigin("TestPolicy", DeviceScope)),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}, CurrentUserScope, NewNamedOrigin("TestPolicy", DeviceScope)),
},
{
name: "with-scope-second-empty",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}, CurrentUserScope),
s2: NewSnapshot(map[Key]RawItem{}),
want: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}, CurrentUserScope),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MergeSnapshots(tt.s1, tt.s2)
if !got.Equal(tt.want) {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
func TestSnapshotEqual(t *testing.T) {
tests := []struct {
name string
s1, s2 *Snapshot
wantEqual bool
wantEqualItems bool
}{
{
name: "nil-nil",
s1: nil,
s2: nil,
wantEqual: true,
wantEqualItems: true,
},
{
name: "nil-empty",
s1: nil,
s2: NewSnapshot(map[Key]RawItem{}),
wantEqual: true,
wantEqualItems: true,
},
{
name: "empty-nil",
s1: NewSnapshot(map[Key]RawItem{}),
s2: nil,
wantEqual: true,
wantEqualItems: true,
},
{
name: "empty-empty",
s1: NewSnapshot(map[Key]RawItem{}),
s2: NewSnapshot(map[Key]RawItem{}),
wantEqual: true,
wantEqualItems: true,
},
{
name: "first-nil",
s1: nil,
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
wantEqual: false,
wantEqualItems: false,
},
{
name: "first-empty",
s1: NewSnapshot(map[Key]RawItem{}),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
wantEqual: false,
wantEqualItems: false,
},
{
name: "second-nil",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: true},
}),
s2: nil,
wantEqual: false,
wantEqualItems: false,
},
{
name: "second-empty",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
s2: NewSnapshot(map[Key]RawItem{}),
wantEqual: false,
wantEqualItems: false,
},
{
name: "same-items-same-order-no-scope",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}),
wantEqual: true,
wantEqualItems: true,
},
{
name: "same-items-same-order-same-scope",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}, DeviceScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}, DeviceScope),
wantEqual: true,
wantEqualItems: true,
},
{
name: "same-items-different-order-same-scope",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}, DeviceScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting3": {value: false},
"Setting1": {value: 123},
"Setting2": {value: "String"},
}, DeviceScope),
wantEqual: true,
wantEqualItems: true,
},
{
name: "same-items-same-order-different-scope",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}, DeviceScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}, CurrentUserScope),
wantEqual: false,
wantEqualItems: true,
},
{
name: "different-items-same-scope",
s1: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 123},
"Setting2": {value: "String"},
"Setting3": {value: false},
}, DeviceScope),
s2: NewSnapshot(map[Key]RawItem{
"Setting4": {value: 2 * time.Hour},
"Setting5": {value: VisibleByPolicy},
"Setting6": {value: ShowChoiceByPolicy},
}, DeviceScope),
wantEqual: false,
wantEqualItems: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotEqual := tt.s1.Equal(tt.s2); gotEqual != tt.wantEqual {
t.Errorf("WantEqual: got %v, want %v", gotEqual, tt.wantEqual)
}
if gotEqualItems := tt.s1.EqualItems(tt.s2); gotEqualItems != tt.wantEqualItems {
t.Errorf("WantEqualItems: got %v, want %v", gotEqualItems, tt.wantEqualItems)
}
})
}
}
func TestSnapshotString(t *testing.T) {
tests := []struct {
name string
snapshot *Snapshot
wantString string
}{
{
name: "nil",
snapshot: nil,
wantString: "{Empty}",
},
{
name: "empty",
snapshot: NewSnapshot(nil),
wantString: "{Empty}",
},
{
name: "empty-with-scope",
snapshot: NewSnapshot(nil, DeviceScope),
wantString: "{Empty, Device}",
},
{
name: "empty-with-origin",
snapshot: NewSnapshot(nil, NewNamedOrigin("Test Policy", DeviceScope)),
wantString: "{Empty, Test Policy (Device)}",
},
{
name: "non-empty",
snapshot: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 2 * time.Hour},
"Setting2": {value: VisibleByPolicy},
"Setting3": {value: ShowChoiceByPolicy},
}, NewNamedOrigin("Test Policy", DeviceScope)),
wantString: `{Test Policy (Device)}
Setting1 = 2h0m0s
Setting2 = show
Setting3 = user-decides`,
},
{
name: "non-empty-with-item-origin",
snapshot: NewSnapshot(map[Key]RawItem{
"Setting1": {value: 42, origin: NewNamedOrigin("Test Policy", DeviceScope)},
}),
wantString: `Setting1 = 42 - {Test Policy (Device)}`,
},
{
name: "non-empty-with-item-error",
snapshot: NewSnapshot(map[Key]RawItem{
"Setting1": {err: NewErrorText("bang!")},
}),
wantString: `Setting1 = Error{"bang!"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotString := tt.snapshot.String(); gotString != tt.wantString {
t.Errorf("got %v\nwant %v", gotString, tt.wantString)
}
})
}
}

View File

@ -0,0 +1,100 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/types/opt"
)
// Summary is an immutable [PolicyScope] and [Origin].
type Summary struct {
data summary
}
type summary struct {
Scope opt.Value[PolicyScope] `json:",omitzero"`
Origin opt.Value[Origin] `json:",omitzero"`
}
// SummaryWith returns a [Summary] with the specified options.
func SummaryWith(opts ...SummaryOption) Summary {
var summary Summary
for _, o := range opts {
o.applySummaryOption(&summary)
}
return summary
}
// IsEmpty reports whether s is empty.
func (s Summary) IsEmpty() bool {
return s == Summary{}
}
// Scope reports the [PolicyScope] in s.
func (s Summary) Scope() opt.Value[PolicyScope] {
return s.data.Scope
}
// Origin reports the [Origin] in s.
func (s Summary) Origin() opt.Value[Origin] {
return s.data.Origin
}
// String implements [fmt.Stringer].
func (s Summary) String() string {
if s.IsEmpty() {
return "{Empty}"
}
if origin, ok := s.data.Origin.GetOk(); ok {
return origin.String()
}
return s.data.Scope.String()
}
// MarshalJSONV2 implements [jsonv2.MarshalerV2].
func (s Summary) MarshalJSONV2(out *jsontext.Encoder, opts jsonv2.Options) error {
return jsonv2.MarshalEncode(out, &s.data, opts)
}
// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2].
func (s *Summary) UnmarshalJSONV2(in *jsontext.Decoder, opts jsonv2.Options) error {
return jsonv2.UnmarshalDecode(in, &s.data, opts)
}
// MarshalJSON implements [json.Marshaler].
func (s Summary) MarshalJSON() ([]byte, error) {
return jsonv2.Marshal(s) // uses MarshalJSONV2
}
// UnmarshalJSON implements [json.Unmarshaler].
func (s *Summary) UnmarshalJSON(b []byte) error {
return jsonv2.Unmarshal(b, s) // uses UnmarshalJSONV2
}
// SummaryOption is an option that configures [Summary]
// The following are allowed options:
//
// - [Summary]
// - [PolicyScope]
// - [Origin]
type SummaryOption interface {
applySummaryOption(summary *Summary)
}
func (s PolicyScope) applySummaryOption(summary *Summary) {
summary.data.Scope.Set(s)
}
func (o Origin) applySummaryOption(summary *Summary) {
summary.data.Origin.Set(o)
if !summary.data.Scope.IsSet() {
summary.data.Scope.Set(o.Scope())
}
}
func (s Summary) applySummaryOption(summary *Summary) {
*summary = s
}

View File

@ -0,0 +1,136 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package setting
import (
"encoding"
)
// PreferenceOption is a policy that governs whether a boolean variable
// is forcibly assigned an administrator-defined value, or allowed to receive
// a user-defined value.
type PreferenceOption byte
const (
ShowChoiceByPolicy PreferenceOption = iota
NeverByPolicy
AlwaysByPolicy
)
// Show returns if the UI option that controls the choice administered by this
// policy should be shown. Currently this is true if and only if the policy is
// [ShowChoiceByPolicy].
func (p PreferenceOption) Show() bool {
return p == ShowChoiceByPolicy
}
// ShouldEnable checks if the choice administered by this policy should be
// enabled. If the administrator has chosen a setting, the administrator's
// setting is returned, otherwise userChoice is returned.
func (p PreferenceOption) ShouldEnable(userChoice bool) bool {
switch p {
case NeverByPolicy:
return false
case AlwaysByPolicy:
return true
default:
return userChoice
}
}
// IsAlways reports whether the preference should always be enabled.
func (p PreferenceOption) IsAlways() bool {
return p == AlwaysByPolicy
}
// IsNever reports whether the preference should always be disabled.
func (p PreferenceOption) IsNever() bool {
return p == NeverByPolicy
}
// WillOverride checks if the choice administered by the policy is different
// from the user's choice.
func (p PreferenceOption) WillOverride(userChoice bool) bool {
return p.ShouldEnable(userChoice) != userChoice
}
// String returns a string representation of p.
func (p PreferenceOption) String() string {
switch p {
case AlwaysByPolicy:
return "always"
case NeverByPolicy:
return "never"
default:
return "user-decides"
}
}
// MarshalText implements [encoding.TextMarshaler].
func (p *PreferenceOption) MarshalText() (text []byte, err error) {
return []byte(p.String()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
// It never fails and sets p to [ShowChoiceByPolicy] if the specified text
// does not represent a valid [PreferenceOption].
func (p *PreferenceOption) UnmarshalText(text []byte) error {
switch string(text) {
case "always":
*p = AlwaysByPolicy
case "never":
*p = NeverByPolicy
default:
*p = ShowChoiceByPolicy
}
return nil
}
// Visibility is a policy that controls whether or not a particular
// component of a user interface is to be shown.
type Visibility byte
var (
_ encoding.TextMarshaler = (*Visibility)(nil)
_ encoding.TextUnmarshaler = (*Visibility)(nil)
)
const (
VisibleByPolicy Visibility = 'v'
HiddenByPolicy Visibility = 'h'
)
// Show reports whether the UI option administered by this policy should be shown.
// Currently this is true if the policy is not [hiddenByPolicy].
func (v Visibility) Show() bool {
return v != HiddenByPolicy
}
// String returns a string representation of v.
func (v Visibility) String() string {
switch v {
case 'h':
return "hide"
default:
return "show"
}
}
// MarshalText implements [encoding.TextMarshaler].
func (v Visibility) MarshalText() (text []byte, err error) {
return []byte(v.String()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
// It never fails and sets v to [VisibleByPolicy] if the specified text
// does not represent a valid [Visibility].
func (v *Visibility) UnmarshalText(text []byte) error {
switch string(text) {
case "hide":
*v = HiddenByPolicy
default:
*v = VisibleByPolicy
}
return nil
}

View File

@ -7,6 +7,8 @@ package syspolicy
import (
"errors"
"time"
"tailscale.com/util/syspolicy/setting"
)
func GetString(key Key, defaultValue string) (string, error) {
@ -45,78 +47,20 @@ func GetStringArray(key Key, defaultValue []string) ([]string, error) {
return v, err
}
// PreferenceOption is a policy that governs whether a boolean variable
// is forcibly assigned an administrator-defined value, or allowed to receive
// a user-defined value.
type PreferenceOption int
const (
showChoiceByPolicy PreferenceOption = iota
neverByPolicy
alwaysByPolicy
)
// Show returns if the UI option that controls the choice administered by this
// policy should be shown. Currently this is true if and only if the policy is
// showChoiceByPolicy.
func (p PreferenceOption) Show() bool {
return p == showChoiceByPolicy
}
// ShouldEnable checks if the choice administered by this policy should be
// enabled. If the administrator has chosen a setting, the administrator's
// setting is returned, otherwise userChoice is returned.
func (p PreferenceOption) ShouldEnable(userChoice bool) bool {
switch p {
case neverByPolicy:
return false
case alwaysByPolicy:
return true
default:
return userChoice
}
}
// WillOverride checks if the choice administered by the policy is different
// from the user's choice.
func (p PreferenceOption) WillOverride(userChoice bool) bool {
return p.ShouldEnable(userChoice) != userChoice
}
// GetPreferenceOption loads a policy from the registry that can be
// managed by an enterprise policy management system and allows administrative
// overrides of users' choices in a way that we do not want tailcontrol to have
// the authority to set. It describes user-decides/always/never options, where
// "always" and "never" remove the user's ability to make a selection. If not
// present or set to a different value, "user-decides" is the default.
func GetPreferenceOption(name Key) (PreferenceOption, error) {
opt, err := GetString(name, "user-decides")
func GetPreferenceOption(name Key) (setting.PreferenceOption, error) {
s, err := GetString(name, "user-decides")
if err != nil {
return showChoiceByPolicy, err
return setting.ShowChoiceByPolicy, err
}
switch opt {
case "always":
return alwaysByPolicy, nil
case "never":
return neverByPolicy, nil
default:
return showChoiceByPolicy, nil
}
}
// Visibility is a policy that controls whether or not a particular
// component of a user interface is to be shown.
type Visibility byte
const (
visibleByPolicy Visibility = 'v'
hiddenByPolicy Visibility = 'h'
)
// Show reports whether the UI option administered by this policy should be shown.
// Currently this is true if and only if the policy is visibleByPolicy.
func (p Visibility) Show() bool {
return p == visibleByPolicy
var opt setting.PreferenceOption
err = opt.UnmarshalText([]byte(s))
return opt, err
}
// GetVisibility loads a policy from the registry that can be managed
@ -124,17 +68,14 @@ func (p Visibility) Show() bool {
// for UI elements. The registry value should be a string set to "show" (return
// true) or "hide" (return true). If not present or set to a different value,
// "show" (return false) is the default.
func GetVisibility(name Key) (Visibility, error) {
opt, err := GetString(name, "show")
func GetVisibility(name Key) (setting.Visibility, error) {
s, err := GetString(name, "show")
if err != nil {
return visibleByPolicy, err
}
switch opt {
case "hide":
return hiddenByPolicy, nil
default:
return visibleByPolicy, nil
return setting.VisibleByPolicy, err
}
var visibility setting.Visibility
visibility.UnmarshalText([]byte(s))
return visibility, nil
}
// GetDuration loads a policy from the registry that can be managed

View File

@ -8,6 +8,8 @@ import (
"slices"
"testing"
"time"
"tailscale.com/util/syspolicy/setting"
)
// testHandler encompasses all data types returned when testing any of the syspolicy
@ -230,38 +232,38 @@ func TestGetPreferenceOption(t *testing.T) {
key Key
handlerValue string
handlerError error
wantValue PreferenceOption
wantValue setting.PreferenceOption
wantError error
}{
{
name: "always by policy",
key: EnableIncomingConnections,
handlerValue: "always",
wantValue: alwaysByPolicy,
wantValue: setting.AlwaysByPolicy,
},
{
name: "never by policy",
key: EnableIncomingConnections,
handlerValue: "never",
wantValue: neverByPolicy,
wantValue: setting.NeverByPolicy,
},
{
name: "use default",
key: EnableIncomingConnections,
handlerValue: "",
wantValue: showChoiceByPolicy,
wantValue: setting.ShowChoiceByPolicy,
},
{
name: "read non-existing value",
key: EnableIncomingConnections,
handlerError: ErrNoSuchKey,
wantValue: showChoiceByPolicy,
wantValue: setting.ShowChoiceByPolicy,
},
{
name: "other error is returned",
key: EnableIncomingConnections,
handlerError: someOtherError,
wantValue: showChoiceByPolicy,
wantValue: setting.ShowChoiceByPolicy,
wantError: someOtherError,
},
}
@ -291,34 +293,34 @@ func TestGetVisibility(t *testing.T) {
key Key
handlerValue string
handlerError error
wantValue Visibility
wantValue setting.Visibility
wantError error
}{
{
name: "hidden by policy",
key: AdminConsoleVisibility,
handlerValue: "hide",
wantValue: hiddenByPolicy,
wantValue: setting.HiddenByPolicy,
},
{
name: "visibility default",
key: AdminConsoleVisibility,
handlerValue: "show",
wantValue: visibleByPolicy,
wantValue: setting.VisibleByPolicy,
},
{
name: "read non-existing value",
key: AdminConsoleVisibility,
handlerValue: "show",
handlerError: ErrNoSuchKey,
wantValue: visibleByPolicy,
wantValue: setting.VisibleByPolicy,
},
{
name: "other error is returned",
key: AdminConsoleVisibility,
handlerValue: "show",
handlerError: someOtherError,
wantValue: visibleByPolicy,
wantValue: setting.VisibleByPolicy,
wantError: someOtherError,
},
}