diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 06ad2f3f7..5ea9e40e8 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -399,7 +399,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+ diff --git a/ipn/doc.go b/ipn/doc.go index 4b3810be1..7294738f2 100644 --- a/ipn/doc.go +++ b/ipn/doc.go @@ -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. diff --git a/ipn/errors.go b/ipn/errors.go new file mode 100644 index 000000000..d4e839b92 --- /dev/null +++ b/ipn/errors.go @@ -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)} +} diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index a5accfb3f..8b1a1db64 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -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 { diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 716d89e0e..4f59c9cf5 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -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 { diff --git a/ipn/ipnauth/access.go b/ipn/ipnauth/access.go new file mode 100644 index 000000000..eebaaa62d --- /dev/null +++ b/ipn/ipnauth/access.go @@ -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, "|") +} diff --git a/ipn/ipnauth/access_check.go b/ipn/ipnauth/access_check.go new file mode 100644 index 000000000..3d5b5a323 --- /dev/null +++ b/ipn/ipnauth/access_check.go @@ -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 +} diff --git a/ipn/ipnauth/access_check_test.go b/ipn/ipnauth/access_check_test.go new file mode 100644 index 000000000..eff2d1acc --- /dev/null +++ b/ipn/ipnauth/access_check_test.go @@ -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) + } +} diff --git a/ipn/ipnauth/access_test.go b/ipn/ipnauth/access_test.go new file mode 100644 index 000000000..4b879daff --- /dev/null +++ b/ipn/ipnauth/access_test.go @@ -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)) + } + } +} diff --git a/ipn/ipnauth/identity.go b/ipn/ipnauth/identity.go new file mode 100644 index 000000000..2762d489d --- /dev/null +++ b/ipn/ipnauth/identity.go @@ -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) +} diff --git a/ipn/ipnauth/identity_test.go b/ipn/ipnauth/identity_test.go new file mode 100644 index 000000000..163dbb553 --- /dev/null +++ b/ipn/ipnauth/identity_test.go @@ -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) + } + }) + } + } + } +} diff --git a/ipn/ipnauth/ipnauth.go b/ipn/ipnauth/ipnauth.go index e6560570c..3943c2a40 100644 --- a/ipn/ipnauth/ipnauth.go +++ b/ipn/ipnauth/ipnauth.go @@ -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) } diff --git a/ipn/ipnauth/ipnauth_notwindows.go b/ipn/ipnauth/ipnauth_notwindows.go index 3dad8233a..d9647fb7b 100644 --- a/ipn/ipnauth/ipnauth_notwindows.go +++ b/ipn/ipnauth/ipnauth_notwindows.go @@ -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. diff --git a/ipn/ipnauth/ipnauth_windows.go b/ipn/ipnauth/ipnauth_windows.go index 9abd04cd1..e057ede49 100644 --- a/ipn/ipnauth/ipnauth_windows.go +++ b/ipn/ipnauth/ipnauth_windows.go @@ -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 diff --git a/ipn/ipnauth/nonwin_test.go b/ipn/ipnauth/nonwin_test.go new file mode 100644 index 000000000..0913e3818 --- /dev/null +++ b/ipn/ipnauth/nonwin_test.go @@ -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) +} diff --git a/ipn/ipnauth/self.go b/ipn/ipnauth/self.go new file mode 100644 index 000000000..b45844040 --- /dev/null +++ b/ipn/ipnauth/self.go @@ -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() +} diff --git a/ipn/ipnauth/testutils.go b/ipn/ipnauth/testutils.go new file mode 100644 index 000000000..824283d9f --- /dev/null +++ b/ipn/ipnauth/testutils.go @@ -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 +} diff --git a/ipn/ipnauth/unix.go b/ipn/ipnauth/unix.go new file mode 100644 index 000000000..70a534c9c --- /dev/null +++ b/ipn/ipnauth/unix.go @@ -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 +} diff --git a/ipn/ipnauth/win.go b/ipn/ipnauth/win.go new file mode 100644 index 000000000..74bf5726f --- /dev/null +++ b/ipn/ipnauth/win.go @@ -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") + } +} diff --git a/ipn/ipnauth/win_test.go b/ipn/ipnauth/win_test.go new file mode 100644 index 000000000..9e0963f00 --- /dev/null +++ b/ipn/ipnauth/win_test.go @@ -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) + } + }) + } + } + } +} diff --git a/ipn/prefs.go b/ipn/prefs.go index f4edeb2d1..53ad4f241 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -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