This commit is contained in:
Nick Khyl 2024-04-27 19:32:47 +00:00 committed by GitHub
commit edcecb388d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2912 additions and 63 deletions

View File

@ -400,7 +400,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+
tailscale.com/version from tailscale.com/client/web+
tailscale.com/version/distro from tailscale.com/client/web+

View File

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/viewer -type=Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
//go:generate go run tailscale.com/cmd/viewer -type=LoginProfile,Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
// Package ipn implements the interactions between the Tailscale cloud
// control plane and the local network stack.

107
ipn/errors.go Normal file
View File

@ -0,0 +1,107 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (
"errors"
"net/http"
)
// AccessDeniedError is an error due to permissions.
type AccessDeniedError struct {
// Err is the underlying error.
Err error
}
// Error returns error message.
func (e *AccessDeniedError) Error() string { return e.Err.Error() }
// Unwrap returns an underlying error.
func (e *AccessDeniedError) Unwrap() error { return e.Err }
// ToHTTPStatus returns http.StatusForbidden.
func (e *AccessDeniedError) ToHTTPStatus() int { return http.StatusForbidden }
// NotFoundError is an error due to a missing resource.
type NotFoundError struct {
// Err is the underlying error.
Err error
}
// Error returns error message.
func (e *NotFoundError) Error() string { return e.Err.Error() }
// Unwrap returns an underlying error.
func (e *NotFoundError) Unwrap() error { return e.Err }
// ToHTTPStatus returns http.StatusNotFound.
func (e *NotFoundError) ToHTTPStatus() int { return http.StatusNotFound }
// BadArgsError is an error due to bad arguments.
type BadArgsError struct {
// Err is the underlying error.
Err error
}
// Error returns error message.
func (e *BadArgsError) Error() string { return e.Err.Error() }
// Unwrap returns an underlying error.
func (e *BadArgsError) Unwrap() error { return e.Err }
// ToHTTPStatus returns http.StatusBadRequest.
func (e *BadArgsError) ToHTTPStatus() int { return http.StatusBadRequest }
// ServiceUnavailableError is an error that can be represented by http.StatusServiceUnavailable.
type ServiceUnavailableError struct {
Err error // Err is the underlying error.
}
// Error returns error message.
func (e *ServiceUnavailableError) Error() string { return e.Err.Error() }
// Unwrap returns an underlying error.
func (e *ServiceUnavailableError) Unwrap() error { return e.Err }
// ToHTTPStatus returns http.StatusServiceUnavailable.
func (e *ServiceUnavailableError) ToHTTPStatus() int { return http.StatusServiceUnavailable }
// InternalServerError is an error that can be represented by http.StatusInternalServerError.
type InternalServerError struct {
Err error // Err is the underlying error.
}
// Error returns error message.
func (e *InternalServerError) Error() string { return e.Err.Error() }
// Unwrap returns an underlying error.
func (e *InternalServerError) Unwrap() error { return e.Err }
// ToHTTPStatus returns http.StatusInternalServerError.
func (e *InternalServerError) ToHTTPStatus() int { return http.StatusInternalServerError }
// NewAccessDeniedError returns a new AccessDeniedError with the specified text.
func NewAccessDeniedError(text string) *AccessDeniedError {
return &AccessDeniedError{errors.New(text)}
}
// NewNotFoundError returns a new NotFoundError with the specified text.
func NewNotFoundError(text string) *NotFoundError {
return &NotFoundError{errors.New(text)}
}
// NewBadArgsError returns a new BadArgsError with the specified text.
func NewBadArgsError(text string) *BadArgsError {
return &BadArgsError{errors.New(text)}
}
// NewServiceUnavailableError returns a new ServiceUnavailableError with the specified text.
func NewServiceUnavailableError(text string) *ServiceUnavailableError {
return &ServiceUnavailableError{errors.New(text)}
}
// NewInternalServerError returns a new InternalServerError with the specified text.
func NewInternalServerError(text string) *InternalServerError {
return &InternalServerError{errors.New(text)}
}

View File

@ -15,6 +15,29 @@ import (
"tailscale.com/types/preftype"
)
// Clone makes a deep copy of LoginProfile.
// The result aliases no memory with the original.
func (src *LoginProfile) Clone() *LoginProfile {
if src == nil {
return nil
}
dst := new(LoginProfile)
*dst = *src
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _LoginProfileCloneNeedsRegeneration = LoginProfile(struct {
ID ProfileID
Name string
NetworkProfile NetworkProfile
Key StateKey
UserProfile tailcfg.UserProfile
NodeID tailcfg.StableNodeID
LocalUserID WindowsUserID
ControlURL string
}{})
// Clone makes a deep copy of Prefs.
// The result aliases no memory with the original.
func (src *Prefs) Clone() *Prefs {

View File

@ -17,7 +17,73 @@ import (
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=LoginProfile,Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
// View returns a readonly view of LoginProfile.
func (p *LoginProfile) View() LoginProfileView {
return LoginProfileView{ж: p}
}
// LoginProfileView provides a read-only view over LoginProfile.
//
// Its methods should only be called if `Valid()` returns true.
type LoginProfileView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *LoginProfile
}
// Valid reports whether underlying value is non-nil.
func (v LoginProfileView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v LoginProfileView) AsStruct() *LoginProfile {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
func (v LoginProfileView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
func (v *LoginProfileView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x LoginProfile
if err := json.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v LoginProfileView) ID() ProfileID { return v.ж.ID }
func (v LoginProfileView) Name() string { return v.ж.Name }
func (v LoginProfileView) NetworkProfile() NetworkProfile { return v.ж.NetworkProfile }
func (v LoginProfileView) Key() StateKey { return v.ж.Key }
func (v LoginProfileView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile }
func (v LoginProfileView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID }
func (v LoginProfileView) LocalUserID() WindowsUserID { return v.ж.LocalUserID }
func (v LoginProfileView) ControlURL() string { return v.ж.ControlURL }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _LoginProfileViewNeedsRegeneration = LoginProfile(struct {
ID ProfileID
Name string
NetworkProfile NetworkProfile
Key StateKey
UserProfile tailcfg.UserProfile
NodeID tailcfg.StableNodeID
LocalUserID WindowsUserID
ControlURL string
}{})
// View returns a readonly view of Prefs.
func (p *Prefs) View() PrefsView {

299
ipn/ipnauth/access.go Normal file
View File

@ -0,0 +1,299 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"math/bits"
"strconv"
"strings"
)
// DeviceAccess is a bitmask representing the requested, required, or granted
// access rights to a device.
type DeviceAccess uint32
// ProfileAccess is a bitmask representing the requested, required, or granted
// access rights to a Tailscale login profile.
type ProfileAccess uint32
// Define access rights for general device management tasks and operations that affect all profiles.
// They are allowed or denied based on the environment and user's role on the device,
// rather than the currently active Tailscale profile.
const (
// ReadDeviceStatus is the access right required to read non-profile specific device statuses,
// such as the IP forwarding status. It is a non-privileged access right generally available to all users.
// It must not grant access to any sensitive or private information,
// including Tailscale profile names, network devices, etc.
ReadDeviceStatus DeviceAccess = 1 << iota
// GenerateBugReport is the access right required to generate a bug report
// (e.g. `tailscale bugreport` in CLI or Debug > Bug Report in GUI).
// It is a non-privileged access right granted to all users.
GenerateBugReport
// CreateProfile is the access right required to create new Tailscale profiles on the device.
// This operation is privileged on Unix-like platforms, including Linux,
// but is available to all users on non-Server Windows devices.
CreateProfile
// Debug is required for debugging operations that could expose sensitive information.
// Many such operations are accessible via `tailscale debug` subcommands.
// It is considered privileged access on all platforms, requiring root access on Unix-like systems
// and elevated admin access on Windows.
Debug
// InstallUpdates is required to initiate a Tailscale client self-update on platforms that support it.
// It is available to all users on all platforms except for Windows Server,
// where it requires admin rights.
InstallUpdates
// DeleteAllProfiles is required to log out from and delete all Tailscale profiles on a device.
// It is considered a privileged operation, requiring root access on Unix-like systems
// and elevated admin access on Windows.
DeleteAllProfiles
// UnrestrictedDeviceAccess combines all possible device access rights.
UnrestrictedDeviceAccess = ^DeviceAccess(0)
)
var deviceAccessNames = map[DeviceAccess]string{
CreateProfile: "CreateProfile",
Debug: "Debug",
DeleteAllProfiles: "DeleteAllProfiles",
GenerateBugReport: "GenerateBugReport",
InstallUpdates: "InstallUpdates",
ReadDeviceStatus: "ReadDeviceStatus",
}
// Define access rights that are specific to individual profiles,
// granted or denied on a per-profile basis.
const (
// ReadProfileInfo is required to view a profile in the list of available profiles and
// to read basic profile info like the user name and tailnet name.
// It also allows to read profile/connection-specific status details, excluding information about peers,
// but must not grant access to any sensitive information, such as private keys.
//
// This access right is granted to all users on Unix-like platforms.
//
// On Windows, any user should have access to their own profiles as well as profiles shared with them.
// NOTE: As of 2024-04-08, the following are the only two ways to share a profile:
// - Create a profile in local system's security context (e.g. via a GP/MDM/SCCM-deployed script);
// - Enable Unattended Mode (ipn.Prefs.ForceDaemon) for the profile.
// We'll reconsider this in tailscale/corp#18342 or subsequent tickets.
// Additionally, Windows admins should be able to list all profiles when running elevated.
//
// If a user does not have ReadProfileInfo access to the current profile, its details will be masked.
ReadProfileInfo ProfileAccess = 1 << iota
// Connect is required to connect to and use a Tailscale profile.
// It is considered a privileged operation on Unix-like platforms and Windows Server.
// On Windows client devices, however, users have the Connect access right
// to the profiles they can read.
Connect
// Disconnect is required to disconnect (or switch from) a Tailscale profile.
// It is considered a privileged operation on Unix-like platforms and Windows Server.
// On Windows Client and other platforms any user should be able to disconnect
// from an active Tailnet.
Disconnect
// DeleteProfile is required to delete a local Tailscale profile.
// Root (or operator) access is required on Unix-like platforms.
// On Windows, profiles can be deleted by their owners. Additionally,
// on Windows Server and managed Windows Client devices, elevated admins have the right
// to delete any profile.
DeleteProfile
// ReauthProfile is required to re-authenticate a Tailscale profile.
// Root (or operator) access is required on Unix-like platforms,
// profile ownership or elevated admin rights is required on Windows.
ReauthProfile
// ListPeers is required to view peer users and devices.
// It is granted to all users on Unix-like platform,
// and to the same users as ReadProfileInfo on Windows.
ListPeers
// ReadPrefs is required to read ipn.Prefs associated with a profile,
// but must not grant access to any sensitive information, such as private keys.
//
// As a general rule, the same users who have ReadProfileInfo access to a profile
// also have the ReadPrefs access right.
ReadPrefs
// ChangePrefs allows changing any preference in ipn.Prefs.
// Root (or operator) access is required on Unix-like platforms.
// Profile ownership or elevated admin rights are required on Windows.
ChangePrefs
// ChangeExitNode allows users without the full ChangePrefs access to select an exit node.
// As of 2024-04-08, it is only used to allow users on non-server, non-managed Windows devices to
// to change an exit node on admin-configured unattended profiles.
ChangeExitNode
// ReadServe is required to read a serve config.
ReadServe
// ChangeServe allows to change a serve config, except for serving a path.
ChangeServe
// ServePath allows to serve an arbitrary path.
// It is a privileged operation that is only available to users that have
// administrative access to the local machine.
ServePath
// SetDNS allows sending a SetDNSRequest request to the control plane server,
// requesting a DNS record be created or updated.
// Root (or operator) access is required on Unix-like platforms.
// Profile ownership or elevated admin rights are required on Windows.
SetDNS
// FetchCerts allows to get an ipnlocal.TLSCertKeyPair for domain, either from cache or via the ACME process.
// On Windows, it's available to the profile owner. On Unix-like platforms, it requires root or operator access,
// or the TS_PERMIT_CERT_UID environment variable set to the userid.
FetchCerts
// ReadPrivateKeys allows reading node's private key.
// Root (or operator) access is required on Unix-like platforms.
// Profile ownership is required on Windows.
ReadPrivateKeys
// ReadTKA allows reading tailnet key authority info.
// Root (or operator) access is required on Unix-like platforms.
// Profile ownership or elevated admin rights are required on Windows.
ReadTKA
// ManageTKA allows managing TKA for a profile.
// Root (or operator) access is required on Unix-like platforms.
// Profile ownership or elevated admin rights are required on Windows.
ManageTKA
// ReceiveFiles allows to receive files via Taildrop.
// Root (or operator) access is required on Unix-like platforms.
// Profile ownership or elevated admin rights are required on Windows.
ReceiveFiles
// UnrestrictedProfileAccess combines all possible profile access rights,
// granting full access to a profile.
UnrestrictedProfileAccess = ^ProfileAccess(0)
)
// Placeholder values for clients to use when rendering the current ipn.LoginProfile
// if the client's user does not have ipnauth.ReadProfileInfo access to the profile.
// However, clients supporting this feature should use UserProfile.ID.IsZero() to determine
// when profile information is not accessible, and render masked profiles
// in a platform-specific, localizable way.
// Clients should avoid checking against these constants, as they are subject to change.
const (
maskedLoginName = "Other User's Account"
maskedDisplayName = "Other User"
maskedProfilePicURL = ""
maskedDomainName = ""
)
var profileAccessNames = map[ProfileAccess]string{
ChangeExitNode: "ChangeExitNode",
ChangePrefs: "ChangePrefs",
ChangeServe: "ChangeServe",
Connect: "Connect",
DeleteProfile: "DeleteProfile",
Disconnect: "Disconnect",
FetchCerts: "FetchCerts",
ListPeers: "ListPeers",
ManageTKA: "ManageTKA",
ReadPrefs: "ReadPrefs",
ReadPrivateKeys: "ReadPrivateKeys",
ReadProfileInfo: "ReadProfileInfo",
ReadServe: "ReadServe",
ReadTKA: "ReadTKA",
ReauthProfile: "ReauthProfile",
ReceiveFiles: "ReceiveFiles",
ServePath: "ServePath",
SetDNS: "SetDNS",
}
var (
deviceAccessBitNames = make([]string, 32)
profileAccessBitNames = make([]string, 32)
)
func init() {
for da, name := range deviceAccessNames {
deviceAccessBitNames[bits.Len32(uint32(da))-1] = name
}
for pa, name := range profileAccessNames {
profileAccessBitNames[bits.Len32(uint32(pa))-1] = name
}
}
// Add adds a to da.
// It is a no-op if da already contains a.
func (da *DeviceAccess) Add(a DeviceAccess) {
*da |= a
}
// Remove removes a from da.
// It is a no-op if da does not contain a.
func (da *DeviceAccess) Remove(a DeviceAccess) {
*da &= ^a
}
// ContainsAll reports whether da contains all access rights specified in a.
func (da *DeviceAccess) ContainsAll(a DeviceAccess) bool {
return (*da & a) == a
}
// Overlaps reports whether da contains any of the access rights specified in a.
func (da *DeviceAccess) Overlaps(a DeviceAccess) bool {
return (*da & a) != 0
}
// String returns a string representation of one or more access rights in da.
// It returns (None) if da is zero.
func (da *DeviceAccess) String() string {
return formatAccessMask(uint32(*da), deviceAccessBitNames)
}
// Add adds a to pa.
// It is a no-op if pa already contains a.
func (pa *ProfileAccess) Add(a ProfileAccess) {
*pa |= a
}
// Remove removes a from pa.
// It is a no-op if pa does not contain a.
func (pa *ProfileAccess) Remove(a ProfileAccess) {
*pa &= ^a
}
// Contains reports whether pa contains all access rights specified in a.
func (pa *ProfileAccess) Contains(a ProfileAccess) bool {
return (*pa & a) == a
}
// Overlaps reports whether pa contains any of the access rights specified in a.
func (pa *ProfileAccess) Overlaps(a ProfileAccess) bool {
return (*pa & a) != 0
}
// String returns a string representation of one or more access rights in pa.
// It returns (None) if pa is zero.
func (pa *ProfileAccess) String() string {
return formatAccessMask(uint32(*pa), profileAccessBitNames)
}
func formatAccessMask(v uint32, flagNames []string) string {
switch {
case v == 0:
return "(None)"
case v == ^uint32(0):
return "(Unrestricted)"
case (v & (v - 1)) == 0:
return flagNames[bits.Len32(v)-1]
default:
return formatAccessMaskSlow(v, flagNames)
}
}
func formatAccessMaskSlow(v uint32, flagNames []string) string {
var rem uint32
flags := make([]string, 0, bits.OnesCount32(v))
for i := 0; i < 32 && v != 0; i++ {
if bf := uint32(1 << i); v&bf != 0 {
if name := flagNames[i]; name != "" {
flags = append(flags, name)
} else {
rem |= bf
}
v &= ^bf
}
}
if rem != 0 {
flags = append(flags, "0x"+strings.ToUpper(strconv.FormatUint(uint64(rem), 16)))
}
return strings.Join(flags, "|")
}

355
ipn/ipnauth/access_check.go Normal file
View File

@ -0,0 +1,355 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"fmt"
"reflect"
"strings"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
)
// errNotAllowed is an error returned when access is neither explicitly allowed,
// nor denied with a more specific error.
var errNotAllowed error = ipn.NewAccessDeniedError("the requested operation is not allowed")
// AccessCheckResult represents the result of an access check.
// Its zero value is valid and indicates that the access request
// has neither been explicitly allowed nor denied for a specific reason.
//
// Higher-level access control code should forward the AccessCheckResult
// from lower-level access control mechanisms to the caller
// immediately upon receiving a definitive result, as indicated
// by the AccessCheckResult.HasResult() method returning true.
//
// Requested access that has not been explicitly allowed
// or explicitly denied is implicitly denied. This is reflected
// in the values returned by AccessCheckResult's Allowed, Denied, and Error methods.
type AccessCheckResult struct {
err error
hasResult bool
}
// AllowAccess returns a new AccessCheckResult indicating that
// the requested access has been allowed.
//
// Access control implementations should return AllowAccess()
// only when they are certain that further access checks
// are unnecessary and the requested access is definitively allowed.
//
// This includes cases where a certain access right, that might
// otherwise be denied based on the environment and normal user rights,
// is explicitly allowed by a corporate admin through syspolicy (GP or MDM).
// It also covers situations where access is not denied by
// higher-level access control mechanisms, such as syspolicy,
// and is granted based on the user's identity, following
// platform and environment-specific rules.
// (e.g., because they are root on Unix or a profile owner on a personal Windows device).
func AllowAccess() AccessCheckResult {
return AccessCheckResult{hasResult: true}
}
// DenyAccess returns a new AccessCheckResult indicating that
// the requested access has been denied with the specified err.
//
// Access control implementations should return DenyAccess()
// as soon as the requested access has been denied, without calling
// any subsequent lower-level access checking mechanisms, if any.
//
// Higher-level access control code should forward the AccessCheckResult
// from any lower-level access check to the caller as soon as it receives
// a definitive result as indicated by the HasResult() method returning true.
// Therefore, if access is denied due to tailscaled config or syspolicy settings,
// it will be immediately denied, regardless of the caller's identity.
func DenyAccess(err error) AccessCheckResult {
if err == nil {
err = ipn.NewInternalServerError("access denied with a nil error")
} else {
err = &ipn.AccessDeniedError{Err: err}
}
return AccessCheckResult{err: err, hasResult: true}
}
// ContinueCheck returns a new AccessCheckResult indicating that
// the requested access has neither been allowed, nor denied,
// and any further access checks should be performed to determine the result.
//
// An an example, a higher level access control code that denies
// certain access rights based on syspolicy may return ContinueCheck()
// to indicate that access is not denied by any applicable policies,
// and lower-level access checks should be performed.
//
// Similarly, if a tailscaled config file is present and restricts certain ipn.Prefs fields
// from being modified, its access checking mechanism should return ContinueCheck()
// when a user tries to change only preferences that are not locked down.
//
// As a general rule, any higher-level access checking code should
// continue calling lower-level access checking code, until it either receives
// and forwards a definitive result from one of the lower-level mechanisms,
// or until there are no additional checks to be performed.
// In the latter case, it can also return ContinueCheck(),
// resulting in the requested access being implicitly denied.
func ContinueCheck() AccessCheckResult {
return AccessCheckResult{}
}
// HasResult reports whether a definitive access decision (either allowed or denied) has been made.
func (r AccessCheckResult) HasResult() bool {
return r.hasResult
}
// Allowed reports whether the requested access has been allowed.
func (r AccessCheckResult) Allowed() bool {
return r.hasResult && r.err == nil
}
// Denied reports whether the requested access should be denied.
func (r AccessCheckResult) Denied() bool {
return !r.hasResult || r.err != nil
}
// Error returns an ipn.AccessDeniedError detailing why access was denied,
// or nil if access has been allowed.
func (r AccessCheckResult) Error() error {
if !r.hasResult && r.err == nil {
return errNotAllowed
}
return r.err
}
// String returns a string representation of r.
func (r AccessCheckResult) String() string {
switch {
case !r.hasResult:
return "Implicit Deny"
case r.err != nil:
return "Deny: " + r.err.Error()
default:
return "Allow"
}
}
// accessChecker is a helper type that allows step-by-step granting or denying of access rights.
type accessChecker[T ~uint32] struct {
remain T // access rights that were requested but have not been granted yet.
res AccessCheckResult
}
// newAccessChecker returns a new accessChecker with the specified requested access.
func newAccessChecker[T ~uint32](requested T) accessChecker[T] {
return accessChecker[T]{remain: requested}
}
// remaining returns the access rights that have been requested but not yet granted.
func (ac *accessChecker[T]) remaining() T {
return ac.remain
}
// result determines if access is Allowed, Denied, or requires further evaluation.
func (ac *accessChecker[T]) result() AccessCheckResult {
if !ac.res.HasResult() && ac.remaining() == 0 {
ac.res = AllowAccess()
}
return ac.res
}
// grant unconditionally grants the specified rights, updating and returning an AccessCheckResult.
func (ac *accessChecker[T]) grant(rights T) AccessCheckResult {
ac.remain &= ^rights
return ac.result()
}
// deny unconditionally denies the specified rights, updating and returning an AccessCheckResult.
// If the specified rights were not requested, it is a no-op.
func (ac *accessChecker[T]) deny(rights T, err error) AccessCheckResult {
if ac.remain&rights != 0 {
ac.res = DenyAccess(err)
}
return ac.result()
}
// tryGrant grants the specified rights and updates the result if those rights have been requested
// and the check does not return an error.
// Otherwise, it is a no-op.
func (ac *accessChecker[T]) tryGrant(rights T, check func() error) AccessCheckResult {
if ac.remain&rights != 0 && check() == nil {
return ac.grant(rights)
}
return ac.result()
}
// mustGrant attempts to grant specified rights if they have been requested.
// If the check fails with an error, that error is used as the reason for access denial.
// If the specified rights were not requested, it is a no-op.
func (ac *accessChecker[T]) mustGrant(rights T, check func() error) AccessCheckResult {
if ac.remain&rights != 0 {
if err := check(); err != nil {
return ac.deny(rights, err)
}
return ac.grant(rights)
}
return ac.result()
}
// CheckAccess reports whether the caller is allowed or denied the desired access.
func CheckAccess(caller Identity, desired DeviceAccess) AccessCheckResult {
// Allow non-user originating changes, such as any changes requested by the control plane.
// We don't want these to be affected by GP/MDM policies or any other restrictions.
if IsUnrestricted(caller) {
return AllowAccess()
}
// TODO(nickkhyl): check syspolicy.
return caller.CheckAccess(desired)
}
// CheckProfileAccess reports whether the caller is allowed or denied the desired access
// to a specific profile and its prefs.
func CheckProfileAccess(caller Identity, profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
// TODO(nickkhyl): consider moving or copying OperatorUser from ipn.Prefs to ipn.LoginProfile,
// as this is the main reason why we need to read prefs here.
// Allow non-user originating changes, such as any changes requested by the control plane.
// We don't want these to be affected by GP/MDM policies or any other restrictions.
if IsUnrestricted(caller) {
return AllowAccess()
}
// TODO(nickkhyl): check syspolicy.
return caller.CheckProfileAccess(profile, prefs, requested)
}
// CheckEditProfile reports whether the caller has access to apply the specified changes to
// the profile and prefs.
func CheckEditProfile(caller Identity, profile ipn.LoginProfileView, prefs ipn.PrefsGetter, changes *ipn.MaskedPrefs) AccessCheckResult {
if IsUnrestricted(caller) {
return AllowAccess()
}
requiredAccess := PrefsChangeRequiredAccess(changes)
return CheckProfileAccess(caller, profile, prefs, requiredAccess)
}
// FilterProfile returns the specified profile, filtering or masking out fields
// inaccessible to the caller. The provided profile value is considered immutable,
// and a new instance of ipn.LoginProfile will be returned if any filtering is necessary.
func FilterProfile(caller Identity, profile ipn.LoginProfileView, prefs ipn.PrefsGetter) ipn.LoginProfileView {
switch {
case CheckProfileAccess(caller, profile, prefs, ReadProfileInfo).Allowed():
return profile
default:
res := &ipn.LoginProfile{
ID: profile.ID(),
Key: profile.Key(),
LocalUserID: profile.LocalUserID(),
UserProfile: maskedUserProfile(profile),
NetworkProfile: maskedNetworkProfile(profile),
}
res.Name = res.UserProfile.LoginName
return res.View()
}
}
// maskedNetworkProfile returns a masked tailcfg.UserProfile for the specified profile.
// The returned value is used by ipnauth.FilterProfile in place of the actual ipn.LoginProfile.UserProfile
// when the caller does not have ipnauth.ReadProfileInfo access to the profile.
//
// Although CLI or GUI clients can render this value as is, it's not localizable, may lead to a suboptimal UX,
// and is provided mainly for compatibility with existing clients.
//
// For an improved UX, CLI and GUI clients should use UserProfile.ID.IsZero() to check
// whether profile information is inaccessible and then render such profiles
// in a platform-specific and localizable way.
func maskedUserProfile(ipn.LoginProfileView) tailcfg.UserProfile {
return tailcfg.UserProfile{
LoginName: maskedLoginName,
DisplayName: maskedDisplayName,
ProfilePicURL: maskedProfilePicURL,
}
}
// maskedNetworkProfile returns a masked ipn.NetworkProfile for the specified profile.
// It is like maskedUserProfile, but for NetworkProfile.
func maskedNetworkProfile(ipn.LoginProfileView) ipn.NetworkProfile {
return ipn.NetworkProfile{
DomainName: maskedDomainName,
}
}
// PrefsChangeRequiredAccess returns the access required to change prefs as requested by mp.
func PrefsChangeRequiredAccess(mp *ipn.MaskedPrefs) ProfileAccess {
masked := reflect.ValueOf(mp).Elem()
return maskedPrefsFieldsAccess(&mp.Prefs, "", masked)
}
// maskedPrefsFieldsAccess returns the access required to change preferences, whose
// corresponding {FieldName}Set flags are set in masked, to the values specified in p.
// The `path` represents a dot-separated path to masked from the ipn.MaskedPrefs root.
func maskedPrefsFieldsAccess(p *ipn.Prefs, path string, masked reflect.Value) ProfileAccess {
var access ProfileAccess
for i := 0; i < masked.NumField(); i++ {
fName := masked.Type().Field(i).Name
if !strings.HasSuffix(fName, "Set") {
continue
}
fName = strings.TrimSuffix(fName, "Set")
fPath := path + fName
fValue := masked.Field(i)
switch fKind := fValue.Kind(); fKind {
case reflect.Bool:
if fValue.Bool() {
access |= prefsFieldRequiredAccess(p, fPath)
}
case reflect.Struct:
access |= maskedPrefsFieldsAccess(p, fPath+".", fValue)
default:
panic(fmt.Sprintf("unsupported mask field kind %v", fKind))
}
}
return access
}
// prefsDefaultFieldAccess is the default ProfileAccess required to modify ipn.Prefs fields
// that do not have access rights overrides.
const prefsDefaultFieldAccess = ChangePrefs
var (
// prefsStaticFieldAccessOverride allows to override ProfileAccess needed to modify ipn.Prefs fields.
// The map uses dot-separated field paths as keys.
prefsStaticFieldAccessOverride = map[string]ProfileAccess{
"ExitNodeID": ChangeExitNode,
"ExitNodeIP": ChangeExitNode,
"ExitNodeAllowLANAccess": ChangeExitNode,
}
// prefsDynamicFieldAccessOverride is like prefsStaticFieldAccessOverride, but it maps field paths
// to functions that dynamically determine ProfileAccess based on the target value to be set.
prefsDynamicFieldAccessOverride = map[string]func(p *ipn.Prefs) ProfileAccess{
"WantRunning": prefsWantRunningRequiredAccess,
}
)
// prefsFieldRequiredAccess returns the access required to change a prefs field
// represented by its field path in ipn.MaskedPrefs to the corresponding value in p.
func prefsFieldRequiredAccess(p *ipn.Prefs, path string) ProfileAccess {
if access, ok := prefsStaticFieldAccessOverride[path]; ok {
return access
}
if accessFn, ok := prefsDynamicFieldAccessOverride[path]; ok {
return accessFn(p)
}
return prefsDefaultFieldAccess
}
// prefsWantRunningRequiredAccess returns the access required to change WantRunning to the value in p.
func prefsWantRunningRequiredAccess(p *ipn.Prefs) ProfileAccess {
if p.WantRunning {
return Connect
}
return Disconnect
}

View File

@ -0,0 +1,550 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"reflect"
"testing"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
)
func TestAccessCheckResult(t *testing.T) {
tests := []struct {
name string
res AccessCheckResult
wantStr string
wantHasResult bool
wantAllow bool
wantDeny bool
wantErr bool
}{
{
name: "zero-value-implicit-deny",
res: AccessCheckResult{},
wantStr: "Implicit Deny",
wantHasResult: false,
wantAllow: false,
wantDeny: true,
wantErr: true,
},
{
name: "continue-implicit-deny",
res: ContinueCheck(),
wantStr: "Implicit Deny",
wantHasResult: false,
wantAllow: false,
wantDeny: true,
wantErr: true,
},
{
name: "explicit-deny",
res: DenyAccess(errNotAllowed),
wantStr: "Deny: " + errNotAllowed.Error(),
wantHasResult: true,
wantAllow: false,
wantDeny: true,
wantErr: true,
},
{
name: "explicit-allow",
res: AllowAccess(),
wantStr: "Allow",
wantHasResult: true,
wantAllow: true,
wantDeny: false,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if gotStr := tt.res.String(); gotStr != tt.wantStr {
t.Errorf("got: %q, want: %q", gotStr, tt.wantStr)
}
if gotHasResult := tt.res.HasResult(); gotHasResult != tt.wantHasResult {
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
}
if gotAllow := tt.res.Allowed(); gotAllow != tt.wantAllow {
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
}
if gotDeny := tt.res.Denied(); gotDeny != tt.wantDeny {
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
}
if gotErr := tt.res.Error(); tt.wantErr {
if _, isAccessDenied := gotErr.(*ipn.AccessDeniedError); !isAccessDenied {
t.Errorf("err: %v, wantErr: %v", gotErr, tt.wantErr)
}
} else if gotErr != nil {
t.Errorf("err: %v, wantErr: %v", gotErr, tt.wantErr)
}
})
}
}
func TestAccessCheckerGrant(t *testing.T) {
tests := []struct {
name string
requested ProfileAccess
grant []ProfileAccess
wantRemaining ProfileAccess
wantHasResult bool
wantAllow bool
wantDeny bool
}{
{
name: "grant-none",
requested: ReadProfileInfo,
grant: []ProfileAccess{},
wantRemaining: ReadProfileInfo,
wantHasResult: false,
wantAllow: false,
wantDeny: true,
},
{
name: "grant-single",
requested: ReadProfileInfo,
grant: []ProfileAccess{ReadProfileInfo},
wantRemaining: 0,
wantHasResult: true,
wantAllow: true,
wantDeny: false,
},
{
name: "grant-other",
requested: ReadProfileInfo,
grant: []ProfileAccess{Connect},
wantRemaining: ReadProfileInfo,
wantHasResult: false,
wantAllow: false,
wantDeny: true,
},
{
name: "grant-some",
requested: ReadProfileInfo | Connect,
grant: []ProfileAccess{ReadProfileInfo},
wantRemaining: Connect,
wantHasResult: false,
wantAllow: false,
wantDeny: true,
},
{
name: "grant-all",
requested: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
grant: []ProfileAccess{ReadProfileInfo, Connect | Disconnect, ReadPrefs},
wantRemaining: 0,
wantHasResult: true,
wantAllow: true,
wantDeny: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := newAccessChecker(tt.requested)
for _, grant := range tt.grant {
checker.grant(grant)
}
if gotRemaining := checker.remaining(); gotRemaining != tt.wantRemaining {
t.Errorf("gotRemaining: %v, wantRemaining: %v", gotRemaining, tt.wantRemaining)
}
res := checker.result()
if gotHasResult := res.HasResult(); gotHasResult != tt.wantHasResult {
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
}
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
}
if gotDeny := res.Denied(); gotDeny != tt.wantDeny {
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
}
})
}
}
func TestAccessCheckerConditionalGrant(t *testing.T) {
tests := []struct {
name string
requested ProfileAccess
mustGrant bool
grant ProfileAccess
predicate func() error
wantRemaining ProfileAccess
wantHasResult bool
wantAllow bool
wantDeny bool
}{
{
name: "try-grant",
requested: ReadProfileInfo,
grant: ReadProfileInfo,
predicate: func() error { return nil },
wantRemaining: 0,
wantHasResult: true,
wantAllow: true,
wantDeny: false,
},
{
name: "try-grant-err",
requested: ReadProfileInfo,
grant: ReadProfileInfo,
predicate: func() error { return errNotAllowed },
wantRemaining: ReadProfileInfo,
wantHasResult: false,
wantAllow: false,
wantDeny: true,
},
{
name: "must-grant",
requested: ReadProfileInfo,
mustGrant: true,
grant: ReadProfileInfo,
predicate: func() error { return nil },
wantRemaining: 0,
wantHasResult: true,
wantAllow: true,
wantDeny: false,
},
{
name: "must-grant-err",
requested: ReadProfileInfo,
mustGrant: true,
grant: ReadProfileInfo,
predicate: func() error { return errNotAllowed },
wantRemaining: ReadProfileInfo,
wantHasResult: true,
wantAllow: false,
wantDeny: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := newAccessChecker(tt.requested)
var res AccessCheckResult
if tt.mustGrant {
res = checker.mustGrant(tt.grant, tt.predicate)
} else {
res = checker.tryGrant(tt.grant, tt.predicate)
}
if gotRemaining := checker.remaining(); gotRemaining != tt.wantRemaining {
t.Errorf("gotRemaining: %v, wantRemaining: %v", gotRemaining, tt.wantRemaining)
}
if gotHasResult := res.HasResult(); gotHasResult != tt.wantHasResult {
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
}
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
}
if gotDeny := res.Denied(); gotDeny != tt.wantDeny {
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
}
})
}
}
func TestAccessCheckerDeny(t *testing.T) {
tests := []struct {
name string
requested ProfileAccess
grant ProfileAccess
deny ProfileAccess
wantHasResult bool
wantAllow bool
wantDeny bool
}{
{
name: "deny-single",
requested: ReadProfileInfo,
deny: ReadProfileInfo,
wantHasResult: true,
wantAllow: false,
wantDeny: true,
},
{
name: "deny-other",
requested: ReadProfileInfo,
deny: Connect,
wantHasResult: false,
wantAllow: false,
wantDeny: true,
},
{
name: "grant-some-then-deny",
requested: ReadProfileInfo | Connect,
grant: ReadProfileInfo,
deny: Connect,
wantHasResult: true,
wantAllow: false,
wantDeny: true,
},
{
name: "deny-some",
requested: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
deny: Connect,
wantHasResult: true,
wantAllow: false,
wantDeny: true,
},
{
name: "deny-all",
requested: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
deny: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
wantHasResult: true,
wantAllow: false,
wantDeny: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := newAccessChecker(tt.requested)
res := checker.grant(tt.grant)
if res.HasResult() {
t.Fatalf("the result must not be ready yet")
}
res = checker.deny(tt.deny, errNotAllowed)
if gotHasResult := res.HasResult(); gotHasResult != tt.wantHasResult {
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
}
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
}
if gotDeny := res.Denied(); gotDeny != tt.wantDeny {
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
}
})
}
}
func TestFilterProfile(t *testing.T) {
profile := &ipn.LoginProfile{
ID: "TEST",
Key: "profile-TEST",
Name: "user@example.com",
NetworkProfile: ipn.NetworkProfile{
MagicDNSName: "example.ts.net",
DomainName: "example.ts.net",
},
UserProfile: tailcfg.UserProfile{
ID: 123456789,
LoginName: "user@example.com",
DisplayName: "User",
ProfilePicURL: "https://example.com/profile.png",
},
NodeID: "TEST-NODE-ID",
LocalUserID: "S-1-5-21-1234567890-1234567890-1234567890-1001",
ControlURL: "https://controlplane.tailscale.com",
}
tests := []struct {
name string
user Identity
profile *ipn.LoginProfile
wantProfile *ipn.LoginProfile
}{
{
name: "filter-unreadable",
user: &TestIdentity{ProfileAccess: 0},
profile: profile,
wantProfile: &ipn.LoginProfile{
ID: profile.ID,
Name: "Other User's Account",
Key: profile.Key,
LocalUserID: profile.LocalUserID,
UserProfile: tailcfg.UserProfile{
LoginName: "Other User's Account",
DisplayName: "Other User",
},
},
},
{
name: "do-not-filter-readable",
user: &TestIdentity{UID: string(profile.LocalUserID), ProfileAccess: ReadProfileInfo},
profile: profile,
wantProfile: profile,
},
{
name: "do-not-filter-for-self",
user: Self,
profile: profile,
wantProfile: profile,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := FilterProfile(tt.user, tt.profile.View(), ipn.PrefsGetterFor(ipn.PrefsView{})).AsStruct()
if !reflect.DeepEqual(profile, tt.wantProfile) {
t.Errorf("got: %+v, want: %+v", profile, tt.wantProfile)
}
})
}
}
func TestPrefsChangeRequiredAccess(t *testing.T) {
tests := []struct {
name string
prefs ipn.MaskedPrefs
wantRequiredAccess ProfileAccess
}{
{
name: "no-changes",
prefs: ipn.MaskedPrefs{},
wantRequiredAccess: 0,
},
{
name: "connect",
prefs: ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
WantRunningSet: true,
},
wantRequiredAccess: Connect,
},
{
name: "disconnect",
prefs: ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: false},
WantRunningSet: true,
},
wantRequiredAccess: Disconnect,
},
{
name: "change-exit-node-id",
prefs: ipn.MaskedPrefs{
ExitNodeIDSet: true,
},
wantRequiredAccess: ChangeExitNode,
},
{
name: "change-exit-node-ip",
prefs: ipn.MaskedPrefs{
ExitNodeIPSet: true,
},
wantRequiredAccess: ChangeExitNode,
},
{
name: "change-exit-node-lan-access",
prefs: ipn.MaskedPrefs{
ExitNodeAllowLANAccessSet: true,
},
wantRequiredAccess: ChangeExitNode,
},
{
name: "change-multiple",
prefs: ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
ExitNodeIDSet: true,
WantRunningSet: true,
},
wantRequiredAccess: Connect | ChangeExitNode,
},
{
name: "change-other-single",
prefs: ipn.MaskedPrefs{
ForceDaemonSet: true,
},
wantRequiredAccess: ChangePrefs,
},
{
name: "change-other-multiple",
prefs: ipn.MaskedPrefs{
ForceDaemonSet: true,
RunSSHSet: true,
},
wantRequiredAccess: ChangePrefs,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotRequiredAccess := PrefsChangeRequiredAccess(&tt.prefs)
if gotRequiredAccess != tt.wantRequiredAccess {
t.Errorf("got: %v, want: %v", gotRequiredAccess, tt.wantRequiredAccess)
}
})
}
}
func TestCheckEditProfile(t *testing.T) {
tests := []struct {
name string
prefs ipn.MaskedPrefs
user Identity
wantAllow bool
}{
{
name: "allow-connect",
prefs: ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
WantRunningSet: true,
},
user: &TestIdentity{ProfileAccess: Connect},
wantAllow: true,
},
{
name: "deny-connect",
prefs: ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
WantRunningSet: true,
},
user: &TestIdentity{ProfileAccess: ReadProfileInfo},
wantAllow: false,
},
{
name: "allow-change-exit-node",
prefs: ipn.MaskedPrefs{
ExitNodeIDSet: true,
},
user: &TestIdentity{ProfileAccess: ChangeExitNode},
wantAllow: true,
},
{
name: "allow-change-prefs",
prefs: ipn.MaskedPrefs{
ForceDaemonSet: true,
RunSSHSet: true,
},
user: &TestIdentity{ProfileAccess: ChangePrefs},
wantAllow: true,
},
{
name: "deny-change-prefs",
prefs: ipn.MaskedPrefs{
ForceDaemonSet: true,
RunSSHSet: true,
},
user: &TestIdentity{ProfileAccess: ChangeExitNode},
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile, prefs := ipn.LoginProfile{}, ipn.NewPrefs()
res := CheckEditProfile(tt.user, profile.View(), ipn.PrefsGetterFor(prefs.View()), &tt.prefs)
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
}
})
}
}
func TestDenyAccessWithNilError(t *testing.T) {
res := DenyAccess(nil)
if gotHasResult := res.HasResult(); !gotHasResult {
t.Errorf("gotHasResult: %v, wantHasResult: true", gotHasResult)
}
if gotAllow := res.Allowed(); gotAllow {
t.Errorf("gotAllow: %v, wantAllow: false", gotAllow)
}
if gotDeny := res.Denied(); !gotDeny {
t.Errorf("gotDeny: %v, wantDeny: true", gotDeny)
}
gotErr := res.Error()
if _, isInternalError := gotErr.(*ipn.InternalServerError); !isInternalError {
t.Errorf("got %T: %v, want: *ipn.InternalServerError", gotErr, gotErr)
}
}

118
ipn/ipnauth/access_test.go Normal file
View File

@ -0,0 +1,118 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"testing"
)
func TestDeviceAccessStringer(t *testing.T) {
tests := []struct {
name string
access DeviceAccess
wantStr string
}{
{
name: "zero-access",
access: 0,
wantStr: "(None)",
},
{
name: "unrestricted-access",
access: ^DeviceAccess(0),
wantStr: "(Unrestricted)",
},
{
name: "single-access",
access: ReadDeviceStatus,
wantStr: "ReadDeviceStatus",
},
{
name: "multi-access",
access: ReadDeviceStatus | GenerateBugReport | DeleteAllProfiles,
wantStr: "ReadDeviceStatus|GenerateBugReport|DeleteAllProfiles",
},
{
name: "unknown-access",
access: DeviceAccess(0xABCD0000),
wantStr: "0xABCD0000",
},
{
name: "multi-with-unknown-access",
access: ReadDeviceStatus | DeviceAccess(0xABCD0000),
wantStr: "ReadDeviceStatus|0xABCD0000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStr := tt.access.String()
if gotStr != tt.wantStr {
t.Errorf("got %v, want %v", gotStr, tt.wantStr)
}
})
}
}
func TestProfileAccessStringer(t *testing.T) {
tests := []struct {
name string
access ProfileAccess
wantStr string
}{
{
name: "zero-access",
access: 0,
wantStr: "(None)",
},
{
name: "unrestricted-access",
access: ^ProfileAccess(0),
wantStr: "(Unrestricted)",
},
{
name: "single-access",
access: ReadProfileInfo,
wantStr: "ReadProfileInfo",
},
{
name: "multi-access",
access: ReadProfileInfo | Connect | Disconnect,
wantStr: "ReadProfileInfo|Connect|Disconnect",
},
{
name: "unknown-access",
access: ProfileAccess(0xFF000000),
wantStr: "0xFF000000",
},
{
name: "multi-with-unknown-access",
access: ReadProfileInfo | ProfileAccess(0xFF000000),
wantStr: "ReadProfileInfo|0xFF000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotStr := tt.access.String()
if gotStr != tt.wantStr {
t.Errorf("got %v, want %v", gotStr, tt.wantStr)
}
})
}
}
func TestNamedDeviceAccessFlagsArePowerOfTwo(t *testing.T) {
for da, name := range deviceAccessNames {
if (da & (da - 1)) != 0 {
t.Errorf("DeviceAccess, %s: got 0x%x, want power of two", name, uint64(da))
}
}
}
func TestNamedProfileAccessFlagsArePowerOfTwo(t *testing.T) {
for pa, name := range profileAccessNames {
if (pa & (pa - 1)) != 0 {
t.Errorf("ProfileAccess, %s: got 0x%x, want power of two", name, uint64(pa))
}
}
}

73
ipn/ipnauth/identity.go Normal file
View File

@ -0,0 +1,73 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"context"
"net"
"tailscale.com/ipn"
"tailscale.com/types/logger"
)
// Identity is any caller identity.
//
// It typically represents a specific OS user, indicating that an operation
// is performed on behalf of this user, should be evaluated against their
// access rights, and performed in their security context when applicable.
//
// However, it can also represent an unrestricted identity (e.g. ipnauth.Self) when an operation
// is executed on behalf of tailscaled itself, in response to a control plane request,
// or when a user's access rights have been verified via other means.
type Identity interface {
// UserID returns an OS-specific UID of the user represented by the identity,
// or "" if the receiver does not represent a specific user.
// As of 2024-04-08, it is only used on Windows.
UserID() ipn.WindowsUserID
// Username returns the user name associated with the receiver,
// or "" if the receiver does not represent a specific user.
Username() (string, error)
// CheckAccess reports whether the receiver is allowed or denied the requested device access.
//
// This method ignores environment factors, Group Policy, and MDM settings that might
// override access permissions at a higher level than individual user identities.
// Therefore, most callers should use ipnauth.CheckAccess instead.
CheckAccess(requested DeviceAccess) AccessCheckResult
// CheckProfileAccess reports whether the receiver is allowed or denied the requested access
// to a specific profile and its prefs.
//
// This method ignores environment factors, Group Policy, and MDM settings that might
// override access permissions at a higher level than individual user identities.
// Therefore, most callers should use ipnauth.CheckProfileAccess instead.
CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult
}
type identityContextKey struct{}
var errNoSecContext = ipn.NewAccessDeniedError("security context not available")
// RequestIdentity returns a user identity associated with ctx,
// or an error if the context does not carry a user's identity.
func RequestIdentity(ctx context.Context) (Identity, error) {
switch v := ctx.Value(identityContextKey{}).(type) {
case Identity:
return v, nil
case error:
return nil, v
case nil:
return nil, errNoSecContext
default:
panic("unreachable")
}
}
// ContextWithConnIdentity returns a new context that carries the identity of the user
// owning the other end of the connection.
func ContextWithConnIdentity(ctx context.Context, logf logger.Logf, c net.Conn) context.Context {
ci, err := GetConnIdentity(logf, c)
if err != nil {
return context.WithValue(ctx, identityContextKey{}, err)
}
return context.WithValue(ctx, identityContextKey{}, ci)
}

View File

@ -0,0 +1,102 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"testing"
"tailscale.com/ipn"
)
var allGOOSes = []string{"linux", "darwin", "windows", "freebsd"}
type accessTest[Access ~uint32] struct {
name string
geese []string
requestAccess []Access
isLocalAdmin bool
wantAllow bool
}
func TestServeAccess(t *testing.T) {
tests := []accessTest[ProfileAccess]{
{
name: "read-serve-not-admin",
geese: allGOOSes,
requestAccess: []ProfileAccess{ReadServe},
isLocalAdmin: false,
wantAllow: true,
},
{
name: "change-serve-not-admin",
geese: []string{"windows"},
requestAccess: []ProfileAccess{ChangeServe},
isLocalAdmin: false,
wantAllow: true,
},
{
name: "change-serve-not-admin",
geese: []string{"linux", "darwin", "freebsd"},
requestAccess: []ProfileAccess{ChangeServe},
isLocalAdmin: false,
wantAllow: false,
},
{
name: "serve-path-not-admin",
geese: allGOOSes,
requestAccess: []ProfileAccess{ServePath},
isLocalAdmin: false,
wantAllow: false,
},
{
name: "serve-path-admin",
geese: allGOOSes,
requestAccess: []ProfileAccess{ServePath},
isLocalAdmin: true,
wantAllow: true,
},
}
runProfileAccessTests(t, tests)
}
func runDeviceAccessTests(t *testing.T, tests []accessTest[DeviceAccess]) {
t.Helper()
for _, tt := range tests {
for _, goos := range tt.geese {
user := NewTestIdentityWithGOOS(goos, "test", tt.isLocalAdmin)
for _, access := range tt.requestAccess {
testName := goos + "-" + tt.name + "-" + access.String()
t.Run(testName, func(t *testing.T) {
res := user.CheckAccess(access)
if res.Allowed() != tt.wantAllow {
t.Errorf("got result = %v, want allow %v", res, tt.wantAllow)
}
})
}
}
}
}
func runProfileAccessTests(t *testing.T, tests []accessTest[ProfileAccess]) {
t.Helper()
for _, tt := range tests {
for _, goos := range tt.geese {
user := NewTestIdentityWithGOOS(goos, "test", tt.isLocalAdmin)
profile := &ipn.LoginProfile{LocalUserID: user.UserID()}
prefs := func() (ipn.PrefsView, error) { return ipn.NewPrefs().View(), nil }
for _, access := range tt.requestAccess {
testName := goos + "-" + tt.name + "-" + access.String()
t.Run(testName, func(t *testing.T) {
res := user.CheckProfileAccess(profile.View(), prefs, access)
if res.Allowed() != tt.wantAllow {
t.Errorf("got result = %v, want allow %v", res, tt.wantAllow)
}
})
}
}
}
}

View File

@ -1,28 +1,22 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package ipnauth controls access to the LocalAPI.
// Package ipnauth controls access to the LocalAPI and LocalBackend.
package ipnauth
import (
"errors"
"fmt"
"io"
"net"
"os"
"os/user"
"runtime"
"strconv"
"github.com/tailscale/peercred"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/safesocket"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/groupmember"
"tailscale.com/util/winutil"
"tailscale.com/version/distro"
)
// ErrNotImplemented is returned by ConnIdentity.WindowsToken when it is not
@ -153,57 +147,6 @@ func (ci *ConnIdentity) IsReadonlyConn(operatorUID string, logf logger.Logf) boo
// has a different last-user-wins auth model.
return false
}
const ro = true
const rw = false
if !safesocket.PlatformUsesPeerCreds() {
return rw
}
creds := ci.creds
if creds == nil {
logf("connection from unknown peer; read-only")
return ro
}
uid, ok := creds.UserID()
if !ok {
logf("connection from peer with unknown userid; read-only")
return ro
}
if uid == "0" {
logf("connection from userid %v; root has access", uid)
return rw
}
if selfUID := os.Getuid(); selfUID != 0 && uid == strconv.Itoa(selfUID) {
logf("connection from userid %v; connection from non-root user matching daemon has access", uid)
return rw
}
if operatorUID != "" && uid == operatorUID {
logf("connection from userid %v; is configured operator", uid)
return rw
}
if yes, err := isLocalAdmin(uid); err != nil {
logf("connection from userid %v; read-only; %v", uid, err)
return ro
} else if yes {
logf("connection from userid %v; is local admin, has access", uid)
return rw
}
logf("connection from userid %v; read-only", uid)
return ro
}
func isLocalAdmin(uid string) (bool, error) {
u, err := user.LookupId(uid)
if err != nil {
return false, err
}
var adminGroup string
switch {
case runtime.GOOS == "darwin":
adminGroup = "admin"
case distro.Get() == distro.QNAP:
adminGroup = "administrators"
default:
return false, fmt.Errorf("no system admin group found")
}
return groupmember.IsMemberOfGroup(adminGroup, u.Username)
user := &unixIdentity{goos: runtime.GOOS, creds: ci.creds}
return !user.isPrivileged(func() string { return operatorUID }, logf)
}

View File

@ -9,9 +9,19 @@ import (
"net"
"github.com/tailscale/peercred"
"tailscale.com/envknob"
"tailscale.com/types/logger"
)
// GetIdentity extracts the identity information from the connection
// based on the user who owns the other end of the connection.
// TODO(nickkhyl): rename this to GetConnIdentity once we no longer need
// the original GetConnIdentity.
func GetIdentity(c net.Conn) (ci Identity, err error) {
creds, _ := peercred.Get(c)
return &unixIdentity{goos: envknob.GOOS(), creds: creds}, nil
}
// GetConnIdentity extracts the identity information from the connection
// based on the user who owns the other end of the connection.
// and couldn't. The returned connIdentity has NotWindows set to true.

View File

@ -14,8 +14,30 @@ import (
"tailscale.com/safesocket"
"tailscale.com/types/logger"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/winenv"
)
// GetConnIdentity extracts the identity information from the connection
// based on the user who owns the other end of the connection.
// If c is not backed by a named pipe, an error is returned.
// TODO(nickkhyl): rename this to GetConnIdentity once we no longer need
// the original GetConnIdentity.
func GetIdentity(c net.Conn) (ci Identity, err error) {
wcc, ok := c.(*safesocket.WindowsClientConn)
if !ok {
return nil, fmt.Errorf("not a WindowsClientConn: %T", c)
}
tok, err := windowsClientToken(wcc)
if err != nil {
return nil, err
}
return newWindowsIdentity(tok, currentWindowsEnvironment()), nil
}
func currentWindowsEnvironment() WindowsEnvironment {
return WindowsEnvironment{IsManaged: winenv.IsManaged(), IsServer: winenv.IsWindowsServer()}
}
// GetConnIdentity extracts the identity information from the connection
// based on the user who owns the other end of the connection.
// If c is not backed by a named pipe, an error is returned.
@ -168,7 +190,12 @@ func (ci *ConnIdentity) WindowsToken() (WindowsToken, error) {
if wcc, ok = ci.conn.(*safesocket.WindowsClientConn); !ok {
return nil, fmt.Errorf("not a WindowsClientConn: %T", ci.conn)
}
return windowsClientToken(wcc)
}
// windowsClientToken returns the WindowsToken representing the security context
// of the connection's client.
func windowsClientToken(wcc *safesocket.WindowsClientConn) (WindowsToken, error) {
// We duplicate the token's handle so that the WindowsToken we return may have
// a lifetime independent from the original connection.
var h windows.Handle

155
ipn/ipnauth/nonwin_test.go Normal file
View File

@ -0,0 +1,155 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"testing"
"tailscale.com/envknob"
"tailscale.com/ipn"
)
var (
unixGOOSes = []string{"linux", "darwin", "freebsd"}
otherGOOSes = []string{"js"}
)
func TestDeviceAccessUnix(t *testing.T) {
tests := []accessTest[DeviceAccess]{
{
name: "allow-read-admin",
geese: unixGOOSes,
requestAccess: []DeviceAccess{ReadDeviceStatus, GenerateBugReport},
isLocalAdmin: true,
wantAllow: true,
},
{
name: "allow-read-non-admin",
geese: unixGOOSes,
requestAccess: []DeviceAccess{ReadDeviceStatus, GenerateBugReport},
isLocalAdmin: false,
wantAllow: true,
},
{
name: "deny-non-read-non-admin",
geese: unixGOOSes,
requestAccess: []DeviceAccess{^(ReadDeviceStatus | GenerateBugReport)},
isLocalAdmin: false,
wantAllow: false,
},
{
name: "allow-all-access-admin",
geese: unixGOOSes,
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
isLocalAdmin: true,
wantAllow: true,
},
}
runDeviceAccessTests(t, tests)
}
func TestDeviceAccessOther(t *testing.T) {
tests := []accessTest[DeviceAccess]{
{
name: "allow-all-access-admin",
geese: otherGOOSes,
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
isLocalAdmin: true,
wantAllow: true,
},
{
name: "allow-all-access-non-admin",
geese: otherGOOSes,
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
isLocalAdmin: false,
wantAllow: true,
},
}
runDeviceAccessTests(t, tests)
}
func TestProfileAccessUnix(t *testing.T) {
tests := []accessTest[ProfileAccess]{
{
name: "allow-read-admin",
geese: unixGOOSes,
requestAccess: []ProfileAccess{ReadProfileInfo, ReadPrefs, ReadServe, ListPeers},
isLocalAdmin: true,
wantAllow: true,
},
{
name: "allow-read-non-admin",
geese: unixGOOSes,
requestAccess: []ProfileAccess{ReadProfileInfo, ReadPrefs, ReadServe, ListPeers},
isLocalAdmin: false,
wantAllow: true,
},
{
name: "deny-non-read-non-admin",
geese: unixGOOSes,
requestAccess: []ProfileAccess{^(ReadProfileInfo | ReadPrefs | ReadServe | ListPeers)},
isLocalAdmin: false,
wantAllow: false,
},
{
name: "allow-use-profile-admin",
geese: unixGOOSes,
requestAccess: []ProfileAccess{Connect, Disconnect, DeleteProfile, ReauthProfile, ChangePrefs, ChangeExitNode},
isLocalAdmin: true,
wantAllow: true,
},
{
name: "deny-use-profile-non-admin",
geese: unixGOOSes,
requestAccess: []ProfileAccess{Connect, Disconnect, DeleteProfile, ReauthProfile, ChangePrefs, ChangeExitNode},
isLocalAdmin: false,
wantAllow: false,
},
{
name: "allow-all-access-admin",
geese: unixGOOSes,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess},
isLocalAdmin: true,
wantAllow: true,
},
}
runProfileAccessTests(t, tests)
}
func TestFetchCertsAccessUnix(t *testing.T) {
for _, goos := range unixGOOSes {
t.Run(goos, func(t *testing.T) {
user := NewTestIdentityWithGOOS(goos, "user", false)
uid := *user.(*unixIdentity).forceForTest.uid
envknob.Setenv("TS_PERMIT_CERT_UID", uid)
defer envknob.Setenv("TS_PERMIT_CERT_UID", "")
profile := ipn.LoginProfile{}
res := user.CheckProfileAccess(profile.View(), ipn.PrefsGetterFor(ipn.NewPrefs().View()), FetchCerts)
if !res.Allowed() {
t.Errorf("got result = %v, want allow", res)
}
})
}
}
func TestProfileAccessOther(t *testing.T) {
tests := []accessTest[ProfileAccess]{
{
name: "allow-all-access-admin",
geese: otherGOOSes,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess},
isLocalAdmin: true,
wantAllow: true,
},
{
name: "allow-all-access-non-admin",
geese: otherGOOSes,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess},
isLocalAdmin: false,
wantAllow: true,
},
}
runProfileAccessTests(t, tests)
}

45
ipn/ipnauth/self.go Normal file
View File

@ -0,0 +1,45 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import "tailscale.com/ipn"
// Self is a caller identity that represents the tailscaled itself and therefore has unlimited access.
//
// It's typically used for operations performed by tailscaled on its own,
// or upon a request from the control plane, rather on behalf of a specific user.
var Self Identity = unrestricted{}
// IsUnrestricted reports whether the specified identity has unrestricted access to the LocalBackend,
// including all user profiles and preferences, serving as a performance optimization
// and ensuring that tailscaled operates correctly, unaffected by Group Policy, MDM, or similar restrictions.
func IsUnrestricted(identity Identity) bool {
if _, ok := identity.(unrestricted); ok {
return true
}
return false
}
type unrestricted struct {
}
// UserID returns an empty string.
func (unrestricted) UserID() ipn.WindowsUserID {
return ""
}
// Username returns an empty string.
func (unrestricted) Username() (string, error) {
return "", nil
}
// CheckAccess always allows the requested access.
func (unrestricted) CheckAccess(desired DeviceAccess) AccessCheckResult {
return AllowAccess()
}
// CheckProfileAccess always allows the requested profile access.
func (unrestricted) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
return AllowAccess()
}

162
ipn/ipnauth/testutils.go Normal file
View File

@ -0,0 +1,162 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"errors"
"runtime"
"tailscale.com/ipn"
"tailscale.com/types/ptr"
)
// TestIdentity is an identity with a predefined UID, Name and access rights.
// It should only be used for testing purposes, and allows external packages
// to test against a specific set of access rights.
type TestIdentity struct {
UID string // UID is an OS-specific user id of the test user.
Name string // Name is the login name of the test user.
DeviceAccess DeviceAccess // DeviceAccess is the test user's access rights on the device.
ProfileAccess ProfileAccess // ProfileAccess is the test user's access rights to Tailscale profiles.
AccessOthersProfiles bool // AccessOthersProfiles indicates whether the test user can access all profiles, regardless of their ownership.
}
var (
// TestAdmin is a test identity that has unrestricted access to the device
// and all Tailscale profiles on it. It should only be used for testing purposes.
TestAdmin = &TestIdentity{
Name: "admin",
DeviceAccess: UnrestrictedDeviceAccess,
ProfileAccess: UnrestrictedProfileAccess,
AccessOthersProfiles: true,
}
)
// NewTestIdentityWithGOOS returns a new test identity for the given GOOS,
// with the specified user name and the isAdmin flag indicating
// whether the user has administrative access on the local machine.
//
// When goos is windows, it returns an identity representing an elevated admin
// or a regular user account on a non-managed non-server environment. Callers
// that require fine-grained control over user's privileges or environment
// should use NewWindowsIdentity instead.
func NewTestIdentityWithGOOS(goos, name string, isAdmin bool) Identity {
if goos == "windows" {
token := &testToken{
SID: ipn.WindowsUserID(name),
Name: name,
Admin: isAdmin,
Elevated: isAdmin,
}
return newWindowsIdentity(token, WindowsEnvironment{})
}
identity := &unixIdentity{goos: goos}
identity.forceForTest.username = ptr.To(name)
identity.forceForTest.isAdmin = ptr.To(isAdmin)
if isAdmin {
identity.forceForTest.uid = ptr.To("0")
} else {
identity.forceForTest.uid = ptr.To("1000")
}
return identity
}
// NewTestIdentity is like NewTestIdentityWithGOOS, but returns a test identity
// for the current platform.
func NewTestIdentity(name string, isAdmin bool) Identity {
return NewTestIdentityWithGOOS(runtime.GOOS, name, isAdmin)
}
// UserID returns t.ID.
func (t *TestIdentity) UserID() ipn.WindowsUserID {
return ipn.WindowsUserID(t.UID)
}
// Username returns t.Name.
func (t *TestIdentity) Username() (string, error) {
return t.Name, nil
}
// CheckAccess reports whether the requested access is allowed or denied
// based on t.DeviceAccess.
func (t *TestIdentity) CheckAccess(requested DeviceAccess) AccessCheckResult {
if requested&t.DeviceAccess == requested {
return AllowAccess()
}
return DenyAccess(errors.New("access denied"))
}
// CheckProfileAccess reports whether the requested profile access is allowed or denied
// based on t.ProfileAccess.
func (t *TestIdentity) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
if !t.AccessOthersProfiles && profile.LocalUserID() != t.UserID() && profile.LocalUserID() != "" {
return DenyAccess(errors.New("the requested profile is owned by another user"))
}
if t.ProfileAccess&requested == requested {
return AllowAccess()
}
return DenyAccess(errors.New("access denied"))
}
// testToken implements WindowsToken and should only be used for testing purposes.
type testToken struct {
SID ipn.WindowsUserID
Name string
Admin, Elevated bool
LocalSystem bool
}
// UID returns t's Security Identifier (SID).
func (t *testToken) UID() (ipn.WindowsUserID, error) {
return t.SID, nil
}
// Username returns t's username.
func (t *testToken) Username() (string, error) {
return t.Name, nil
}
// IsAdministrator reports whether t represents an admin's,
// but not necessarily elevated, security context.
func (t *testToken) IsAdministrator() (bool, error) {
return t.Admin, nil
}
// IsElevated reports whether t represents an elevated security context,
// such as of LocalSystem or "Run as administrator".
func (t *testToken) IsElevated() bool {
return t.Elevated || t.IsLocalSystem()
}
// IsLocalSystem reports whether t represents a LocalSystem's security context.
func (t *testToken) IsLocalSystem() bool {
return t.LocalSystem
}
// UserDir is not implemented.
func (t *testToken) UserDir(folderID string) (string, error) {
return "", errors.New("Not implemented")
}
// Close is a no-op.
func (t *testToken) Close() error {
return nil
}
// EqualUIDs reports whether two WindowsTokens have the same UIDs.
func (t *testToken) EqualUIDs(other WindowsToken) bool {
if t != nil && other == nil || t == nil && other != nil {
return false
}
ot, ok := other.(*testToken)
if !ok {
return false
}
return t == ot || t.SID == ot.SID
}
// IsUID reports whether t has the specified UID.
func (t *testToken) IsUID(uid ipn.WindowsUserID) bool {
return t.SID == uid
}

322
ipn/ipnauth/unix.go Normal file
View File

@ -0,0 +1,322 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"runtime"
"strconv"
"time"
"github.com/tailscale/peercred"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/safesocket"
"tailscale.com/types/logger"
"tailscale.com/util/groupmember"
"tailscale.com/util/osuser"
"tailscale.com/version"
"tailscale.com/version/distro"
)
var (
errMustBeRootOrOperator = ipn.NewAccessDeniedError("must be root or an operator")
errMustBeRootOrSudoerOperator = ipn.NewAccessDeniedError("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
)
var _ Identity = (*unixIdentity)(nil)
// unixIdentity is a non-Windows user identity.
type unixIdentity struct {
goos string
creds *peercred.Creds // or nil
// forceForTest are fields used exclusively for testing purposes.
// Only non-nil values within this struct are used.
forceForTest struct {
uid, username *string
isAdmin *bool
}
}
// UserID returns the empty string; it exists only to implement ipnauth.Identity.
func (id *unixIdentity) UserID() ipn.WindowsUserID {
return ""
}
// Username returns the user name associated with the identity.
func (id *unixIdentity) Username() (string, error) {
if id.forceForTest.username != nil {
return *id.forceForTest.username, nil
}
switch id.goos {
case "darwin", "linux":
uid, ok := id.creds.UserID()
if !ok {
return "", errors.New("missing user ID")
}
u, err := osuser.LookupByUID(uid)
if err != nil {
return "", fmt.Errorf("lookup user: %w", err)
}
return u.Username, nil
default:
return "", errors.New("unsupported OS")
}
}
// CheckAccess reports whether user is allowed or denied the requested access.
func (id *unixIdentity) CheckAccess(requested DeviceAccess) AccessCheckResult {
if id.isPrivileged(nil, logger.Discard) {
return AllowAccess()
}
allowed := GenerateBugReport | ReadDeviceStatus | InstallUpdates
if requested&^allowed == 0 {
return AllowAccess()
}
return DenyAccess(errMustBeRootOrOperator)
}
// CheckProfileAccess reports whether user is allowed or denied the requested access to the profile.
func (id *unixIdentity) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
operatorUID := operatorUIDFromPrefs(prefs)
checker := newAccessChecker(requested)
// Deny access immediately if ServePath was requested, unless the user is root,
// or both a sudoer and an operator.
if checker.remaining()&ServePath != 0 {
if !id.canServePath(operatorUID) {
return checker.deny(ServePath, errMustBeRootOrSudoerOperator)
}
if res := checker.grant(ServePath); res.HasResult() {
return res
}
}
// Grant non-privileges access to everyone.
if res := checker.grant(ReadProfileInfo | ListPeers | ReadPrefs | ReadServe); res.HasResult() {
return res
}
// Grant all access to root, admins and the operator.
if id.isPrivileged(operatorUID, logger.Discard) {
if res := checker.grant(UnrestrictedProfileAccess); res.HasResult() {
return res
}
}
// Grant cert fetching access to the TS_PERMIT_CERT_UID user.
if id.canFetchCerts() {
if res := checker.grant(FetchCerts); res.HasResult() {
return res
}
}
// Deny any other access.
return DenyAccess(errMustBeRootOrOperator)
}
func operatorUIDFromPrefs(prefs ipn.PrefsGetter) func() string {
return func() string {
prefs, err := prefs()
if err != nil {
return ""
}
opUserName := prefs.OperatorUser()
if opUserName == "" {
return ""
}
u, err := user.Lookup(opUserName)
if err != nil {
return ""
}
return u.Uid
}
}
// isPrivileged reports whether the identity should be considered privileged,
// meaning it's allowed to change the state of the node and access sensitive information.
func (id *unixIdentity) isPrivileged(operatorUID func() string, logf logger.Logf) bool {
if logf == nil {
logf = func(format string, args ...any) {
fmt.Printf("%s", fmt.Sprintf(format, args...))
}
}
const ro, rw = false, true
if !safesocket.GOOSUsesPeerCreds(id.goos) {
return rw
}
if id.forceForTest.isAdmin != nil {
return *id.forceForTest.isAdmin
}
creds := id.creds
if creds == nil {
logf("connection from unknown peer; read-only")
return ro
}
uid, ok := creds.UserID()
if !ok {
logf("connection from peer with unknown userid; read-only")
return ro
}
if uid == "0" {
logf("connection from userid %v; root has access", uid)
return rw
}
if selfUID := os.Getuid(); selfUID != 0 && uid == strconv.Itoa(selfUID) {
logf("connection from userid %v; connection from non-root user matching daemon has access", uid)
return rw
}
if operatorUID != nil {
if operatorUID := operatorUID(); operatorUID != "" && uid == operatorUID {
logf("connection from userid %v; is configured operator", uid)
return rw
}
}
if yes, err := isLocalAdmin(uid); err != nil {
logf("connection from userid %v; read-only; %v", uid, err)
return ro
} else if yes {
logf("connection from userid %v; is local admin, has access", uid)
return rw
}
logf("connection from userid %v; read-only", uid)
return ro
}
// canFetchCerts reports whether id is allowed to fetch HTTPS
// certs from this server when it wouldn't otherwise be able to.
//
// That is, this reports whether id should grant additional
// capabilities over what the conn would otherwise be able to do.
//
// For now this only returns true on Unix machines when
// TS_PERMIT_CERT_UID is set the to the userid of the peer
// connection. It's intended to give your non-root webserver access
// (www-data, caddy, nginx, etc) to certs.
func (id *unixIdentity) canFetchCerts() bool {
var uid string
var hasUID bool
if id.forceForTest.uid != nil {
uid, hasUID = *id.forceForTest.uid, true
} else if id.creds != nil {
uid, hasUID = id.creds.UserID()
}
if hasUID && uid == userIDFromString(envknob.String("TS_PERMIT_CERT_UID")) {
return true
}
return false
}
func (id *unixIdentity) canServePath(operatorUID func() string) bool {
switch id.goos {
case "linux", "darwin":
// continue
case "windows":
panic("unreachable")
default:
return id.isPrivileged(operatorUID, logger.Discard)
}
// Only check for local admin on tailscaled-on-mac (based on "sudo"
// permissions). On sandboxed variants (MacSys and AppStore), tailscaled
// cannot serve files outside of the sandbox and this check is not
// relevant.
if id.goos == "darwin" && version.IsSandboxedMacOS() {
return true
}
return id.isLocalAdminForServe(operatorUID)
}
// isLocalAdminForServe reports whether the identity representing a connected client
// has administrative access to the local machine, for whatever that means with respect to the
// current OS.
//
// This is useful because tailscaled itself always runs with elevated rights:
// we want to avoid privilege escalation for certain mutative operations.
func (id *unixIdentity) isLocalAdminForServe(operatorUID func() string) bool {
if id.forceForTest.isAdmin != nil {
return *id.forceForTest.isAdmin
}
switch id.goos {
case "darwin":
// Unknown, or at least unchecked on sandboxed macOS variants. Err on
// the side of less permissions.
//
// canSetServePath should not call connIsLocalAdmin on sandboxed variants anyway.
if version.IsSandboxedMacOS() {
return false
}
// This is a standalone tailscaled setup, use the same logic as on
// Linux.
fallthrough
case "linux":
uid, ok := id.creds.UserID()
if !ok {
return false
}
// root is always admin.
if uid == "0" {
return true
}
// if non-root, must be operator AND able to execute "sudo tailscale".
if operatorUID := operatorUID(); operatorUID != "" && uid != operatorUID {
return false
}
u, err := osuser.LookupByUID(uid)
if err != nil {
return false
}
// Short timeout just in case sudo hangs for some reason.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil {
return false
}
return true
default:
return false
}
}
func isLocalAdmin(uid string) (bool, error) {
u, err := user.LookupId(uid)
if err != nil {
return false, err
}
var adminGroup string
switch {
case runtime.GOOS == "darwin":
adminGroup = "admin"
case distro.Get() == distro.QNAP:
adminGroup = "administrators"
default:
return false, errors.New("no system admin group found")
}
return groupmember.IsMemberOfGroup(adminGroup, u.Username)
}
// userIDFromString maps from either a numeric user id in string form
// ("998") or username ("caddy") to its string userid ("998").
// It returns the empty string on error.
func userIDFromString(v string) string {
if v == "" || isAllDigit(v) {
return v
}
u, err := user.Lookup(v)
if err != nil {
return ""
}
return u.Uid
}
func isAllDigit(s string) bool {
for i := 0; i < len(s); i++ {
if b := s[i]; b < '0' || b > '9' {
return false
}
}
return true
}

221
ipn/ipnauth/win.go Normal file
View File

@ -0,0 +1,221 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"errors"
"runtime"
"tailscale.com/ipn"
)
var _ Identity = (*windowsIdentity)(nil)
// windowsIdentity represents identity of a Windows user.
type windowsIdentity struct {
tok WindowsToken
env WindowsEnvironment
}
// newWindowsIdentity returns a new WindowsIdentity with the specified token and environment.
func newWindowsIdentity(tok WindowsToken, env WindowsEnvironment) *windowsIdentity {
identity := &windowsIdentity{tok, env}
runtime.SetFinalizer(identity, func(i *windowsIdentity) { i.Close() })
return identity
}
// UserID returns SID of a Windows user account.
func (wi *windowsIdentity) UserID() ipn.WindowsUserID {
if uid, err := wi.tok.UID(); err == nil {
return uid
}
return ""
}
// UserID returns SID of a Windows user account.
func (wi *windowsIdentity) Username() (string, error) {
return wi.tok.Username()
}
// CheckAccess reports whether wi is allowed or denied the requested access.
func (wi *windowsIdentity) CheckAccess(requested DeviceAccess) AccessCheckResult {
checker := newAccessChecker(requested)
// Debug and ResetAllProfiles access rights can only be granted to elevated admins.
if res := checker.mustGrant(DeleteAllProfiles|Debug, wi.checkElevatedAdmin); res.HasResult() {
return res
}
if wi.env.IsServer {
// Only admins can create new profiles or install client updates on Windows Server devices.
// However, we should allow these operations from non-elevated contexts (e.g GUI).
if res := checker.tryGrant(CreateProfile|InstallUpdates, wi.checkAdmin); res.HasResult() {
return res
}
} else {
// But any user should be able to create a profile or initiate an update on non-server (e.g. Windows 10/11) devices.
if res := checker.grant(CreateProfile | InstallUpdates); res.HasResult() {
return res
}
}
// Unconditionally grant ReadStatus and GenerateBugReport to all authenticated users, regardless of the environment.
if res := checker.grant(ReadDeviceStatus | GenerateBugReport); res.HasResult() {
return res
}
// Grant unrestricted device access to elevated admins.
if res := checker.tryGrant(UnrestrictedDeviceAccess, wi.checkElevatedAdmin); res.HasResult() {
return res
}
// Returns the final access check result, implicitly denying any access rights that have not been explicitly granted.
return checker.result()
}
// CheckProfileAccess reports whether wi is allowed or denied the requested access to the profile.
func (wi *windowsIdentity) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
checker := newAccessChecker(requested)
// To avoid privilege escalation, the ServePath access right must only be granted to elevated admins.
// The access request will be immediately denied if wi is not an elevated admin.
if res := checker.mustGrant(ServePath, wi.checkElevatedAdmin); res.HasResult() {
return res
}
// Profile owners have unrestricted access to their own profiles.
if wi.isProfileOwner(profile) {
if res := checker.grant(UnrestrictedProfileAccess); res.HasResult() {
return res
}
}
if isProfileShared(profile, prefs) {
// Allow all users to read basic profile info (e.g. profile and tailnet name)
// and list network device for shared profiles.
// Profile is considered shared if it has unattended mode enabled
// and/or is not owned by a specific user (e.g. created via MDM/GP).
sharedProfileRights := ReadProfileInfo | ListPeers
if !wi.env.IsServer && !wi.env.IsManaged {
// Additionally, on non-managed Windows client devices we should allow users to
// connect / disconnect, read preferences and select exit nodes.
sharedProfileRights |= Connect | Disconnect | ReadPrefs | ChangeExitNode
}
if res := checker.grant(sharedProfileRights); res.HasResult() {
return res
}
}
if !wi.env.IsServer && !isProfileEnforced(profile, prefs) {
// Allow any user to disconnect from non-enforced Tailnets on non-Windows Server devices.
// TODO(nickkhyl): automatically disconnect from the current Tailnet
// when a different user logs in or unlocks their Windows session,
// unless the unattended mode is enabled. But in the meantime, we should allow users
// to disconnect themselves.
if res := checker.grant(Disconnect); res.HasResult() {
return res
}
}
if isAdmin, _ := wi.tok.IsAdministrator(); isAdmin {
// Allow local admins to disconnect from any tailnet.
localAdminRights := Disconnect
if wi.tok.IsElevated() {
// Allow elevated admins unrestricted access to all local profiles,
// except for reading private keys.
localAdminRights |= UnrestrictedProfileAccess & ^ReadPrivateKeys
}
if isProfileShared(profile, prefs) {
// Allow all admins unrestricted access to shared profiles,
// except for reading private keys.
// This is to allow shared profiles created by others (admins or users)
// to be managed from the GUI client.
localAdminRights |= UnrestrictedProfileAccess & ^ReadPrivateKeys
}
if res := checker.grant(localAdminRights); res.HasResult() {
return res
}
}
return checker.result()
}
// Close implements io.Closer by releasing resources associated with the Windows user identity.
func (wi *windowsIdentity) Close() error {
if wi == nil || wi.tok == nil {
return nil
}
if err := wi.tok.Close(); err != nil {
return err
}
runtime.SetFinalizer(wi, nil)
wi.tok = nil
return nil
}
func (wi *windowsIdentity) checkAdmin() error {
isAdmin, err := wi.tok.IsAdministrator()
if err != nil {
return err
}
if !isAdmin {
return errors.New("the requested operation requires local admin rights")
}
return nil
}
func (wi *windowsIdentity) checkElevatedAdmin() error {
if !wi.tok.IsElevated() {
return errors.New("the requested operation requires elevation")
}
return nil
}
func (wi *windowsIdentity) isProfileOwner(profile ipn.LoginProfileView) bool {
return wi.tok.IsUID(profile.LocalUserID())
}
// isProfileShared reports whether the specified profile is considered shared,
// meaning that all local users should have at least ReadProfileInfo and ListPeers
// access to it, but may be granted additional access rights based on the environment
// and their role on the device.
func isProfileShared(profile ipn.LoginProfileView, prefs ipn.PrefsGetter) bool {
if profile.LocalUserID() == "" {
// Profiles created as LocalSystem (e.g. via MDM) can be used by everyone on the device.
return true
}
if prefs, err := prefs(); err == nil {
// Profiles that have unattended mode enabled can be used by everyone on the device.
return prefs.ForceDaemon()
}
return false
}
func isProfileEnforced(ipn.LoginProfileView, ipn.PrefsGetter) bool {
// TODO(nickkhyl): allow to mark profiles as enforced to prevent
// regular users from disconnecting.
return false
}
// WindowsEnvironment describes the current Windows environment.
type WindowsEnvironment struct {
IsServer bool // whether running on a server edition of Windows
IsManaged bool // whether the device is managed (domain-joined or MDM-enrolled)
}
// String returns a string representation of the environment.
func (env WindowsEnvironment) String() string {
switch {
case env.IsManaged && env.IsServer:
return "Managed Server"
case env.IsManaged && !env.IsServer:
return "Managed Client"
case !env.IsManaged && env.IsServer:
return "Non-Managed Server"
case !env.IsManaged && !env.IsServer:
return "Non-Managed Client"
default:
panic("unreachable")
}
}

258
ipn/ipnauth/win_test.go Normal file
View File

@ -0,0 +1,258 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnauth
import (
"testing"
"tailscale.com/ipn"
)
var (
winServerEnvs = []WindowsEnvironment{
{IsServer: true, IsManaged: false},
{IsServer: true, IsManaged: true},
}
winClientEnvs = []WindowsEnvironment{
{IsServer: false, IsManaged: false},
{IsServer: false, IsManaged: true},
}
winManagedEnvs = []WindowsEnvironment{
{IsServer: false, IsManaged: true},
{IsServer: true, IsManaged: true},
}
winAllEnvs = []WindowsEnvironment{
{IsServer: false, IsManaged: false},
{IsServer: false, IsManaged: true},
{IsServer: true, IsManaged: false},
{IsServer: true, IsManaged: true},
}
)
func TestDeviceAccessWindows(t *testing.T) {
tests := []struct {
name string
requestAccess []DeviceAccess
envs []WindowsEnvironment
tok WindowsToken
wantAllow bool
}{
{
name: "allow-all-access-elevated-admin",
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
envs: winAllEnvs,
tok: &testToken{Admin: true, Elevated: true},
wantAllow: true,
},
{
name: "allow-create-profile-non-elevated-admin",
requestAccess: []DeviceAccess{CreateProfile},
envs: winAllEnvs,
tok: &testToken{Admin: true, Elevated: false},
wantAllow: true,
},
{
name: "allow-install-updates-non-elevated-admin",
requestAccess: []DeviceAccess{InstallUpdates},
envs: winAllEnvs,
tok: &testToken{Admin: true, Elevated: false},
wantAllow: true,
},
{
name: "deny-privileged-access-non-elevated-admin",
requestAccess: []DeviceAccess{Debug, DeleteAllProfiles},
envs: winAllEnvs,
tok: &testToken{Admin: true, Elevated: false},
wantAllow: false,
},
{
name: "allow-read-access-user",
requestAccess: []DeviceAccess{ReadDeviceStatus, GenerateBugReport},
envs: winAllEnvs,
tok: &testToken{Admin: false},
wantAllow: true,
},
{
name: "deny-privileged-access-user",
requestAccess: []DeviceAccess{Debug, DeleteAllProfiles},
envs: winAllEnvs,
tok: &testToken{Admin: false},
wantAllow: false,
},
{
name: "allow-create-profile-non-server-user",
requestAccess: []DeviceAccess{CreateProfile},
envs: winClientEnvs,
tok: &testToken{Admin: false},
wantAllow: true,
},
{
name: "deny-create-profile-server-user",
requestAccess: []DeviceAccess{CreateProfile},
envs: winServerEnvs,
tok: &testToken{Admin: false},
wantAllow: false,
},
{
name: "allow-install-updates-non-server-user",
requestAccess: []DeviceAccess{InstallUpdates},
envs: winClientEnvs,
tok: &testToken{Admin: false},
wantAllow: true,
},
{
name: "deny-install-updates-server-user",
requestAccess: []DeviceAccess{InstallUpdates},
envs: winServerEnvs,
tok: &testToken{Admin: false},
wantAllow: false,
},
}
for _, tt := range tests {
for _, env := range tt.envs {
user := newWindowsIdentity(tt.tok, env)
for _, access := range tt.requestAccess {
testName := tt.name + "-" + env.String() + "-" + access.String()
t.Run(testName, func(t *testing.T) {
if res := user.CheckAccess(access); res.Allowed() != tt.wantAllow {
t.Errorf("got result: %v, want allow: %v", res, tt.wantAllow)
}
})
}
}
}
}
func TestProfileAccessWindows(t *testing.T) {
tests := []struct {
name string
tok WindowsToken
profile ipn.LoginProfile
prefs ipn.Prefs
envs []WindowsEnvironment
requestAccess []ProfileAccess
wantAllow bool
}{
{
name: "allow-users-access-to-own-profiles",
tok: &testToken{Admin: false, SID: "User1"},
profile: ipn.LoginProfile{LocalUserID: "User1"},
envs: winAllEnvs,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^(ServePath)}, // ServePath requires elevated admin rights
wantAllow: true,
},
{
name: "allow-users-disconnect-access-to-others-profiles-on-clients",
tok: &testToken{Admin: false, SID: "User1"},
profile: ipn.LoginProfile{LocalUserID: "User2"},
envs: winClientEnvs,
requestAccess: []ProfileAccess{Disconnect},
wantAllow: true,
},
{
name: "allow-users-access-to-others-unattended-profiles-on-unmanaged-clients",
tok: &testToken{Admin: false, SID: "User1"},
profile: ipn.LoginProfile{LocalUserID: "User2"},
prefs: ipn.Prefs{ForceDaemon: true},
envs: []WindowsEnvironment{{IsServer: false, IsManaged: false}},
requestAccess: []ProfileAccess{ReadProfileInfo, Connect, Disconnect, ListPeers, ReadPrefs, ChangeExitNode},
wantAllow: true,
},
{
name: "allow-users-read-access-to-others-unattended-profiles-on-managed",
tok: &testToken{Admin: false, SID: "User1"},
profile: ipn.LoginProfile{LocalUserID: "User2"},
prefs: ipn.Prefs{ForceDaemon: true},
envs: winManagedEnvs,
requestAccess: []ProfileAccess{ReadProfileInfo, ListPeers},
wantAllow: true,
},
{
name: "allow-users-read-access-to-others-unattended-profiles-on-servers",
tok: &testToken{Admin: false, SID: "User1"},
profile: ipn.LoginProfile{LocalUserID: "User2"},
prefs: ipn.Prefs{ForceDaemon: true},
envs: winServerEnvs,
requestAccess: []ProfileAccess{ReadProfileInfo, ListPeers},
wantAllow: true,
},
{
name: "deny-users-access-to-non-unattended-others-profiles",
tok: &testToken{Admin: false, SID: "User1"},
profile: ipn.LoginProfile{LocalUserID: "User2"},
envs: winAllEnvs,
requestAccess: []ProfileAccess{ReadProfileInfo, Connect, ListPeers, ReadPrefs, ChangePrefs, ChangeExitNode},
wantAllow: false,
},
{
name: "allow-elevated-admins-access-to-others-profiles",
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
profile: ipn.LoginProfile{LocalUserID: "User1"},
envs: winAllEnvs,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^ReadPrivateKeys}, // ReadPrivateKeys is never allowed to others' profiles.
wantAllow: true,
},
{
name: "allow-non-elevated-admins-access-to-shared-profiles",
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
profile: ipn.LoginProfile{LocalUserID: ""},
envs: winManagedEnvs,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^(ReadPrivateKeys | ServePath)},
wantAllow: true,
},
{
name: "allow-non-elevated-admins-access-to-unattended-profiles",
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
profile: ipn.LoginProfile{LocalUserID: ""},
prefs: ipn.Prefs{ForceDaemon: true},
envs: winManagedEnvs,
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^(ReadPrivateKeys | ServePath)},
wantAllow: true,
},
{
name: "deny-non-elevated-admins-access-to-others-profiles",
tok: &testToken{Admin: true, Elevated: false, SID: "Admin1"},
profile: ipn.LoginProfile{LocalUserID: "User1"},
envs: winAllEnvs,
requestAccess: []ProfileAccess{ReadProfileInfo, Connect, ListPeers, ReadPrefs, ChangePrefs, ChangeExitNode},
wantAllow: false,
},
{
name: "allow-elevated-admins-serve-path-for-own-profiles",
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
profile: ipn.LoginProfile{LocalUserID: "Admin1"},
envs: winAllEnvs,
requestAccess: []ProfileAccess{ServePath},
wantAllow: true,
},
{
name: "deny-non-elevated-admins-serve-path-for-own-profiles",
tok: &testToken{Admin: true, Elevated: false, SID: "Admin1"},
profile: ipn.LoginProfile{LocalUserID: "Admin1"},
envs: winAllEnvs,
requestAccess: []ProfileAccess{ServePath},
wantAllow: false,
},
}
for _, tt := range tests {
for _, env := range tt.envs {
user := newWindowsIdentity(tt.tok, env)
for _, access := range tt.requestAccess {
testName := tt.name + "-" + env.String() + "-" + access.String()
t.Run(testName, func(t *testing.T) {
res := user.CheckProfileAccess(tt.profile.View(), ipn.PrefsGetterFor(tt.prefs.View()), access)
if res.Allowed() != tt.wantAllow {
t.Errorf("got result: %v, want allow: %v", res, tt.wantAllow)
}
})
}
}
}
}

View File

@ -245,6 +245,19 @@ type Prefs struct {
Persist *persist.Persist `json:"Config"`
}
// PrefsGetter is any function that returns a PrefsView or an error.
// It delays fetching of PrefsView from the StateStore until and unless it is needed.
// This is primarily used when ipnauth needs to access Prefs.OperatorUser on Linux
// or Prefs.ForceDaemon on Windows.
// TODO(nickkhyl): consider moving / copying fields that are used in access checks
// from ipn.Prefs to ipn.LoginProfile.
type PrefsGetter func() (PrefsView, error)
// PrefsGetterFor returns a new PrefsGetter that always return the specified p.
func PrefsGetterFor(p PrefsView) PrefsGetter {
return func() (PrefsView, error) { return p, nil }
}
// AutoUpdatePrefs are the auto update settings for the node agent.
type AutoUpdatePrefs struct {
// Check specifies whether background checks for updates are enabled. When