diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 825b33fac..d4e70488e 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -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 diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index b016d5727..a6f7fba01 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -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+ diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index c03be655d..4bc7a4526 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -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+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 42a1c4579..3cc9927d8 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -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+ diff --git a/util/syspolicy/internal/internal.go b/util/syspolicy/internal/internal.go new file mode 100644 index 000000000..4c3e28d39 --- /dev/null +++ b/util/syspolicy/internal/internal.go @@ -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 +} diff --git a/util/syspolicy/policy_keys.go b/util/syspolicy/policy_keys.go index ef0cfed8f..a88025205 100644 --- a/util/syspolicy/policy_keys.go +++ b/util/syspolicy/policy_keys.go @@ -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 diff --git a/util/syspolicy/setting/errors.go b/util/syspolicy/setting/errors.go new file mode 100644 index 000000000..d7e14df83 --- /dev/null +++ b/util/syspolicy/setting/errors.go @@ -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 +} diff --git a/util/syspolicy/setting/key.go b/util/syspolicy/setting/key.go new file mode 100644 index 000000000..406fde132 --- /dev/null +++ b/util/syspolicy/setting/key.go @@ -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 = "/" diff --git a/util/syspolicy/setting/origin.go b/util/syspolicy/setting/origin.go new file mode 100644 index 000000000..078ef758e --- /dev/null +++ b/util/syspolicy/setting/origin.go @@ -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 +} diff --git a/util/syspolicy/setting/policy_scope.go b/util/syspolicy/setting/policy_scope.go new file mode 100644 index 000000000..55fa339e7 --- /dev/null +++ b/util/syspolicy/setting/policy_scope.go @@ -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 +} diff --git a/util/syspolicy/setting/policy_scope_test.go b/util/syspolicy/setting/policy_scope_test.go new file mode 100644 index 000000000..e1b6cf7ea --- /dev/null +++ b/util/syspolicy/setting/policy_scope_test.go @@ -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) + } + }) + } +} diff --git a/util/syspolicy/setting/raw_item.go b/util/syspolicy/setting/raw_item.go new file mode 100644 index 000000000..30480d892 --- /dev/null +++ b/util/syspolicy/setting/raw_item.go @@ -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) +} diff --git a/util/syspolicy/setting/setting.go b/util/syspolicy/setting/setting.go new file mode 100644 index 000000000..93be287b1 --- /dev/null +++ b/util/syspolicy/setting/setting.go @@ -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) + } +} diff --git a/util/syspolicy/setting/setting_test.go b/util/syspolicy/setting/setting_test.go new file mode 100644 index 000000000..3cc08e7da --- /dev/null +++ b/util/syspolicy/setting/setting_test.go @@ -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() +} diff --git a/util/syspolicy/setting/snapshot.go b/util/syspolicy/setting/snapshot.go new file mode 100644 index 000000000..306bf759e --- /dev/null +++ b/util/syspolicy/setting/snapshot.go @@ -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...)} +} diff --git a/util/syspolicy/setting/snapshot_test.go b/util/syspolicy/setting/snapshot_test.go new file mode 100644 index 000000000..e198d4a58 --- /dev/null +++ b/util/syspolicy/setting/snapshot_test.go @@ -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) + } + }) + } +} diff --git a/util/syspolicy/setting/summary.go b/util/syspolicy/setting/summary.go new file mode 100644 index 000000000..5ff20e0aa --- /dev/null +++ b/util/syspolicy/setting/summary.go @@ -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 +} diff --git a/util/syspolicy/setting/types.go b/util/syspolicy/setting/types.go new file mode 100644 index 000000000..9f110ab03 --- /dev/null +++ b/util/syspolicy/setting/types.go @@ -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 +} diff --git a/util/syspolicy/syspolicy.go b/util/syspolicy/syspolicy.go index 76e11e2b6..ccfd83347 100644 --- a/util/syspolicy/syspolicy.go +++ b/util/syspolicy/syspolicy.go @@ -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 diff --git a/util/syspolicy/syspolicy_test.go b/util/syspolicy/syspolicy_test.go index c2810ebbb..8280aa1df 100644 --- a/util/syspolicy/syspolicy_test.go +++ b/util/syspolicy/syspolicy_test.go @@ -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, }, }