mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-01 14:05:39 +00:00
ipn, ipn/ipnauth: implement API surface for LocalBackend access checking
We have a lot of access checks spread around the ipnserver, ipnlocal, localapi, and ipnauth packages, with a significant number of platform-specific checks that are used exclusively on either Windows or Unix-like platforms. Additionally, with the exception of a few Windows-specific checks, most of these checks are per-device rather than per-profile, which is not always correct even on single-user/single-session environments, but even more problematic on multi-user/multi-session environments such as Windows. We initially attempted to map all possible operations onto the permitRead/permitWrite access flags. However, these flags are not utilized on Windows and prove insufficient on Unix machines. Specifically, on Windows, the first user to connect is granted full access, while subsequent logged-in users have no access to the LocalAPI at all. This restriction applies regardless of the environment, local user roles (e.g., whether a Windows user is a local admin), or whether they are the active user on a shared Windows client device. Conversely, on Unix, we introduced the permitCert flag to enable granting non-root web servers (such as www-data, caddy, nginx, etc.) access to certificates. We also added additional access check to distinguish local admins (root on Unix-like platforms, elevated admins on Windows) from users with permitWrite access, and used it as a fix for the serve path LPE. A more fine-grained access control system could better suit our current and future needs, especially in improving the UX across various scenarios on corporate and personal Windows devices. This adds an API surface in ipnauth that will be used in LocalBackend to check access to individual Tailscale profiles as well as any device-wide information and operations. Updates tailscale/corp#18342 Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
parent
9e1c86901b
commit
551d6ae0f3
@ -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+
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:generate go run tailscale.com/cmd/viewer -type=Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
|
||||
//go:generate go run tailscale.com/cmd/viewer -type=LoginProfile,Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
|
||||
|
||||
// Package ipn implements the interactions between the Tailscale cloud
|
||||
// control plane and the local network stack.
|
||||
|
107
ipn/errors.go
Normal file
107
ipn/errors.go
Normal file
@ -0,0 +1,107 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AccessDeniedError is an error due to permissions.
|
||||
type AccessDeniedError struct {
|
||||
// Err is the underlying error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns error message.
|
||||
func (e *AccessDeniedError) Error() string { return e.Err.Error() }
|
||||
|
||||
// Unwrap returns an underlying error.
|
||||
func (e *AccessDeniedError) Unwrap() error { return e.Err }
|
||||
|
||||
// ToHTTPStatus returns http.StatusForbidden.
|
||||
func (e *AccessDeniedError) ToHTTPStatus() int { return http.StatusForbidden }
|
||||
|
||||
// NotFoundError is an error due to a missing resource.
|
||||
type NotFoundError struct {
|
||||
// Err is the underlying error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns error message.
|
||||
func (e *NotFoundError) Error() string { return e.Err.Error() }
|
||||
|
||||
// Unwrap returns an underlying error.
|
||||
func (e *NotFoundError) Unwrap() error { return e.Err }
|
||||
|
||||
// ToHTTPStatus returns http.StatusNotFound.
|
||||
func (e *NotFoundError) ToHTTPStatus() int { return http.StatusNotFound }
|
||||
|
||||
// BadArgsError is an error due to bad arguments.
|
||||
type BadArgsError struct {
|
||||
// Err is the underlying error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns error message.
|
||||
func (e *BadArgsError) Error() string { return e.Err.Error() }
|
||||
|
||||
// Unwrap returns an underlying error.
|
||||
func (e *BadArgsError) Unwrap() error { return e.Err }
|
||||
|
||||
// ToHTTPStatus returns http.StatusBadRequest.
|
||||
func (e *BadArgsError) ToHTTPStatus() int { return http.StatusBadRequest }
|
||||
|
||||
// ServiceUnavailableError is an error that can be represented by http.StatusServiceUnavailable.
|
||||
type ServiceUnavailableError struct {
|
||||
Err error // Err is the underlying error.
|
||||
}
|
||||
|
||||
// Error returns error message.
|
||||
func (e *ServiceUnavailableError) Error() string { return e.Err.Error() }
|
||||
|
||||
// Unwrap returns an underlying error.
|
||||
func (e *ServiceUnavailableError) Unwrap() error { return e.Err }
|
||||
|
||||
// ToHTTPStatus returns http.StatusServiceUnavailable.
|
||||
func (e *ServiceUnavailableError) ToHTTPStatus() int { return http.StatusServiceUnavailable }
|
||||
|
||||
// InternalServerError is an error that can be represented by http.StatusInternalServerError.
|
||||
type InternalServerError struct {
|
||||
Err error // Err is the underlying error.
|
||||
}
|
||||
|
||||
// Error returns error message.
|
||||
func (e *InternalServerError) Error() string { return e.Err.Error() }
|
||||
|
||||
// Unwrap returns an underlying error.
|
||||
func (e *InternalServerError) Unwrap() error { return e.Err }
|
||||
|
||||
// ToHTTPStatus returns http.StatusInternalServerError.
|
||||
func (e *InternalServerError) ToHTTPStatus() int { return http.StatusInternalServerError }
|
||||
|
||||
// NewAccessDeniedError returns a new AccessDeniedError with the specified text.
|
||||
func NewAccessDeniedError(text string) *AccessDeniedError {
|
||||
return &AccessDeniedError{errors.New(text)}
|
||||
}
|
||||
|
||||
// NewNotFoundError returns a new NotFoundError with the specified text.
|
||||
func NewNotFoundError(text string) *NotFoundError {
|
||||
return &NotFoundError{errors.New(text)}
|
||||
}
|
||||
|
||||
// NewBadArgsError returns a new BadArgsError with the specified text.
|
||||
func NewBadArgsError(text string) *BadArgsError {
|
||||
return &BadArgsError{errors.New(text)}
|
||||
}
|
||||
|
||||
// NewServiceUnavailableError returns a new ServiceUnavailableError with the specified text.
|
||||
func NewServiceUnavailableError(text string) *ServiceUnavailableError {
|
||||
return &ServiceUnavailableError{errors.New(text)}
|
||||
}
|
||||
|
||||
// NewInternalServerError returns a new InternalServerError with the specified text.
|
||||
func NewInternalServerError(text string) *InternalServerError {
|
||||
return &InternalServerError{errors.New(text)}
|
||||
}
|
@ -15,6 +15,29 @@
|
||||
"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 {
|
||||
|
@ -17,7 +17,73 @@
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=LoginProfile,Prefs,ServeConfig,TCPPortHandler,HTTPHandler,WebServerConfig
|
||||
|
||||
// View returns a readonly view of LoginProfile.
|
||||
func (p *LoginProfile) View() LoginProfileView {
|
||||
return LoginProfileView{ж: p}
|
||||
}
|
||||
|
||||
// LoginProfileView provides a read-only view over LoginProfile.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type LoginProfileView struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *LoginProfile
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v LoginProfileView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v LoginProfileView) AsStruct() *LoginProfile {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v LoginProfileView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *LoginProfileView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x LoginProfile
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v LoginProfileView) ID() ProfileID { return v.ж.ID }
|
||||
func (v LoginProfileView) Name() string { return v.ж.Name }
|
||||
func (v LoginProfileView) NetworkProfile() NetworkProfile { return v.ж.NetworkProfile }
|
||||
func (v LoginProfileView) Key() StateKey { return v.ж.Key }
|
||||
func (v LoginProfileView) UserProfile() tailcfg.UserProfile { return v.ж.UserProfile }
|
||||
func (v LoginProfileView) NodeID() tailcfg.StableNodeID { return v.ж.NodeID }
|
||||
func (v LoginProfileView) LocalUserID() WindowsUserID { return v.ж.LocalUserID }
|
||||
func (v LoginProfileView) ControlURL() string { return v.ж.ControlURL }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _LoginProfileViewNeedsRegeneration = LoginProfile(struct {
|
||||
ID ProfileID
|
||||
Name string
|
||||
NetworkProfile NetworkProfile
|
||||
Key StateKey
|
||||
UserProfile tailcfg.UserProfile
|
||||
NodeID tailcfg.StableNodeID
|
||||
LocalUserID WindowsUserID
|
||||
ControlURL string
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of Prefs.
|
||||
func (p *Prefs) View() PrefsView {
|
||||
|
299
ipn/ipnauth/access.go
Normal file
299
ipn/ipnauth/access.go
Normal file
@ -0,0 +1,299 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"math/bits"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeviceAccess is a bitmask representing the requested, required, or granted
|
||||
// access rights to a device.
|
||||
type DeviceAccess uint32
|
||||
|
||||
// ProfileAccess is a bitmask representing the requested, required, or granted
|
||||
// access rights to a Tailscale login profile.
|
||||
type ProfileAccess uint32
|
||||
|
||||
// Define access rights for general device management tasks and operations that affect all profiles.
|
||||
// They are allowed or denied based on the environment and user's role on the device,
|
||||
// rather than the currently active Tailscale profile.
|
||||
const (
|
||||
// ReadDeviceStatus is the access right required to read non-profile specific device statuses,
|
||||
// such as the IP forwarding status. It is a non-privileged access right generally available to all users.
|
||||
// It must not grant access to any sensitive or private information,
|
||||
// including Tailscale profile names, network devices, etc.
|
||||
ReadDeviceStatus DeviceAccess = 1 << iota
|
||||
// GenerateBugReport is the access right required to generate a bug report
|
||||
// (e.g. `tailscale bugreport` in CLI or Debug > Bug Report in GUI).
|
||||
// It is a non-privileged access right granted to all users.
|
||||
GenerateBugReport
|
||||
// CreateProfile is the access right required to create new Tailscale profiles on the device.
|
||||
// This operation is privileged on Unix-like platforms, including Linux,
|
||||
// but is available to all users on non-Server Windows devices.
|
||||
CreateProfile
|
||||
// Debug is required for debugging operations that could expose sensitive information.
|
||||
// Many such operations are accessible via `tailscale debug` subcommands.
|
||||
// It is considered privileged access on all platforms, requiring root access on Unix-like systems
|
||||
// and elevated admin access on Windows.
|
||||
Debug
|
||||
// InstallUpdates is required to initiate a Tailscale client self-update on platforms that support it.
|
||||
// It is available to all users on all platforms except for Windows Server,
|
||||
// where it requires admin rights.
|
||||
InstallUpdates
|
||||
// DeleteAllProfiles is required to log out from and delete all Tailscale profiles on a device.
|
||||
// It is considered a privileged operation, requiring root access on Unix-like systems
|
||||
// and elevated admin access on Windows.
|
||||
DeleteAllProfiles
|
||||
|
||||
// UnrestrictedDeviceAccess combines all possible device access rights.
|
||||
UnrestrictedDeviceAccess = ^DeviceAccess(0)
|
||||
)
|
||||
|
||||
var deviceAccessNames = map[DeviceAccess]string{
|
||||
CreateProfile: "CreateProfile",
|
||||
Debug: "Debug",
|
||||
DeleteAllProfiles: "DeleteAllProfiles",
|
||||
GenerateBugReport: "GenerateBugReport",
|
||||
InstallUpdates: "InstallUpdates",
|
||||
ReadDeviceStatus: "ReadDeviceStatus",
|
||||
}
|
||||
|
||||
// Define access rights that are specific to individual profiles,
|
||||
// granted or denied on a per-profile basis.
|
||||
const (
|
||||
// ReadProfileInfo is required to view a profile in the list of available profiles and
|
||||
// to read basic profile info like the user name and tailnet name.
|
||||
// It also allows to read profile/connection-specific status details, excluding information about peers,
|
||||
// but must not grant access to any sensitive information, such as private keys.
|
||||
//
|
||||
// This access right is granted to all users on Unix-like platforms.
|
||||
//
|
||||
// On Windows, any user should have access to their own profiles as well as profiles shared with them.
|
||||
// NOTE: As of 2024-04-08, the following are the only two ways to share a profile:
|
||||
// - Create a profile in local system's security context (e.g. via a GP/MDM/SCCM-deployed script);
|
||||
// - Enable Unattended Mode (ipn.Prefs.ForceDaemon) for the profile.
|
||||
// We'll reconsider this in tailscale/corp#18342 or subsequent tickets.
|
||||
// Additionally, Windows admins should be able to list all profiles when running elevated.
|
||||
//
|
||||
// If a user does not have ReadProfileInfo access to the current profile, its details will be masked.
|
||||
ReadProfileInfo ProfileAccess = 1 << iota
|
||||
// Connect is required to connect to and use a Tailscale profile.
|
||||
// It is considered a privileged operation on Unix-like platforms and Windows Server.
|
||||
// On Windows client devices, however, users have the Connect access right
|
||||
// to the profiles they can read.
|
||||
Connect
|
||||
// Disconnect is required to disconnect (or switch from) a Tailscale profile.
|
||||
// It is considered a privileged operation on Unix-like platforms and Windows Server.
|
||||
// On Windows Client and other platforms any user should be able to disconnect
|
||||
// from an active Tailnet.
|
||||
Disconnect
|
||||
// DeleteProfile is required to delete a local Tailscale profile.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// On Windows, profiles can be deleted by their owners. Additionally,
|
||||
// on Windows Server and managed Windows Client devices, elevated admins have the right
|
||||
// to delete any profile.
|
||||
DeleteProfile
|
||||
// ReauthProfile is required to re-authenticate a Tailscale profile.
|
||||
// Root (or operator) access is required on Unix-like platforms,
|
||||
// profile ownership or elevated admin rights is required on Windows.
|
||||
ReauthProfile
|
||||
// ListPeers is required to view peer users and devices.
|
||||
// It is granted to all users on Unix-like platform,
|
||||
// and to the same users as ReadProfileInfo on Windows.
|
||||
ListPeers
|
||||
|
||||
// ReadPrefs is required to read ipn.Prefs associated with a profile,
|
||||
// but must not grant access to any sensitive information, such as private keys.
|
||||
//
|
||||
// As a general rule, the same users who have ReadProfileInfo access to a profile
|
||||
// also have the ReadPrefs access right.
|
||||
ReadPrefs
|
||||
// ChangePrefs allows changing any preference in ipn.Prefs.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// Profile ownership or elevated admin rights are required on Windows.
|
||||
ChangePrefs
|
||||
// ChangeExitNode allows users without the full ChangePrefs access to select an exit node.
|
||||
// As of 2024-04-08, it is only used to allow users on non-server, non-managed Windows devices to
|
||||
// to change an exit node on admin-configured unattended profiles.
|
||||
ChangeExitNode
|
||||
|
||||
// ReadServe is required to read a serve config.
|
||||
ReadServe
|
||||
// ChangeServe allows to change a serve config, except for serving a path.
|
||||
ChangeServe
|
||||
// ServePath allows to serve an arbitrary path.
|
||||
// It is a privileged operation that is only available to users that have
|
||||
// administrative access to the local machine.
|
||||
ServePath
|
||||
|
||||
// SetDNS allows sending a SetDNSRequest request to the control plane server,
|
||||
// requesting a DNS record be created or updated.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// Profile ownership or elevated admin rights are required on Windows.
|
||||
SetDNS
|
||||
// FetchCerts allows to get an ipnlocal.TLSCertKeyPair for domain, either from cache or via the ACME process.
|
||||
// On Windows, it's available to the profile owner. On Unix-like platforms, it requires root or operator access,
|
||||
// or the TS_PERMIT_CERT_UID environment variable set to the userid.
|
||||
FetchCerts
|
||||
// ReadPrivateKeys allows reading node's private key.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// Profile ownership is required on Windows.
|
||||
ReadPrivateKeys
|
||||
|
||||
// ReadTKA allows reading tailnet key authority info.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// Profile ownership or elevated admin rights are required on Windows.
|
||||
ReadTKA
|
||||
// ManageTKA allows managing TKA for a profile.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// Profile ownership or elevated admin rights are required on Windows.
|
||||
ManageTKA
|
||||
|
||||
// ReceiveFiles allows to receive files via Taildrop.
|
||||
// Root (or operator) access is required on Unix-like platforms.
|
||||
// Profile ownership or elevated admin rights are required on Windows.
|
||||
ReceiveFiles
|
||||
|
||||
// UnrestrictedProfileAccess combines all possible profile access rights,
|
||||
// granting full access to a profile.
|
||||
UnrestrictedProfileAccess = ^ProfileAccess(0)
|
||||
)
|
||||
|
||||
// Placeholder values for clients to use when rendering the current ipn.LoginProfile
|
||||
// if the client's user does not have ipnauth.ReadProfileInfo access to the profile.
|
||||
// However, clients supporting this feature should use UserProfile.ID.IsZero() to determine
|
||||
// when profile information is not accessible, and render masked profiles
|
||||
// in a platform-specific, localizable way.
|
||||
// Clients should avoid checking against these constants, as they are subject to change.
|
||||
const (
|
||||
maskedLoginName = "Other User's Account"
|
||||
maskedDisplayName = "Other User"
|
||||
maskedProfilePicURL = ""
|
||||
maskedDomainName = ""
|
||||
)
|
||||
|
||||
var profileAccessNames = map[ProfileAccess]string{
|
||||
ChangeExitNode: "ChangeExitNode",
|
||||
ChangePrefs: "ChangePrefs",
|
||||
ChangeServe: "ChangeServe",
|
||||
Connect: "Connect",
|
||||
DeleteProfile: "DeleteProfile",
|
||||
Disconnect: "Disconnect",
|
||||
FetchCerts: "FetchCerts",
|
||||
ListPeers: "ListPeers",
|
||||
ManageTKA: "ManageTKA",
|
||||
ReadPrefs: "ReadPrefs",
|
||||
ReadPrivateKeys: "ReadPrivateKeys",
|
||||
ReadProfileInfo: "ReadProfileInfo",
|
||||
ReadServe: "ReadServe",
|
||||
ReadTKA: "ReadTKA",
|
||||
ReauthProfile: "ReauthProfile",
|
||||
ReceiveFiles: "ReceiveFiles",
|
||||
ServePath: "ServePath",
|
||||
SetDNS: "SetDNS",
|
||||
}
|
||||
|
||||
var (
|
||||
deviceAccessBitNames = make([]string, 32)
|
||||
profileAccessBitNames = make([]string, 32)
|
||||
)
|
||||
|
||||
func init() {
|
||||
for da, name := range deviceAccessNames {
|
||||
deviceAccessBitNames[bits.Len32(uint32(da))-1] = name
|
||||
}
|
||||
for pa, name := range profileAccessNames {
|
||||
profileAccessBitNames[bits.Len32(uint32(pa))-1] = name
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a to da.
|
||||
// It is a no-op if da already contains a.
|
||||
func (da *DeviceAccess) Add(a DeviceAccess) {
|
||||
*da |= a
|
||||
}
|
||||
|
||||
// Remove removes a from da.
|
||||
// It is a no-op if da does not contain a.
|
||||
func (da *DeviceAccess) Remove(a DeviceAccess) {
|
||||
*da &= ^a
|
||||
}
|
||||
|
||||
// ContainsAll reports whether da contains all access rights specified in a.
|
||||
func (da *DeviceAccess) ContainsAll(a DeviceAccess) bool {
|
||||
return (*da & a) == a
|
||||
}
|
||||
|
||||
// Overlaps reports whether da contains any of the access rights specified in a.
|
||||
func (da *DeviceAccess) Overlaps(a DeviceAccess) bool {
|
||||
return (*da & a) != 0
|
||||
}
|
||||
|
||||
// String returns a string representation of one or more access rights in da.
|
||||
// It returns (None) if da is zero.
|
||||
func (da *DeviceAccess) String() string {
|
||||
return formatAccessMask(uint32(*da), deviceAccessBitNames)
|
||||
}
|
||||
|
||||
// Add adds a to pa.
|
||||
// It is a no-op if pa already contains a.
|
||||
func (pa *ProfileAccess) Add(a ProfileAccess) {
|
||||
*pa |= a
|
||||
}
|
||||
|
||||
// Remove removes a from pa.
|
||||
// It is a no-op if pa does not contain a.
|
||||
func (pa *ProfileAccess) Remove(a ProfileAccess) {
|
||||
*pa &= ^a
|
||||
}
|
||||
|
||||
// Contains reports whether pa contains all access rights specified in a.
|
||||
func (pa *ProfileAccess) Contains(a ProfileAccess) bool {
|
||||
return (*pa & a) == a
|
||||
}
|
||||
|
||||
// Overlaps reports whether pa contains any of the access rights specified in a.
|
||||
func (pa *ProfileAccess) Overlaps(a ProfileAccess) bool {
|
||||
return (*pa & a) != 0
|
||||
}
|
||||
|
||||
// String returns a string representation of one or more access rights in pa.
|
||||
// It returns (None) if pa is zero.
|
||||
func (pa *ProfileAccess) String() string {
|
||||
return formatAccessMask(uint32(*pa), profileAccessBitNames)
|
||||
}
|
||||
|
||||
func formatAccessMask(v uint32, flagNames []string) string {
|
||||
switch {
|
||||
case v == 0:
|
||||
return "(None)"
|
||||
case v == ^uint32(0):
|
||||
return "(Unrestricted)"
|
||||
case (v & (v - 1)) == 0:
|
||||
return flagNames[bits.Len32(v)-1]
|
||||
default:
|
||||
return formatAccessMaskSlow(v, flagNames)
|
||||
}
|
||||
}
|
||||
|
||||
func formatAccessMaskSlow(v uint32, flagNames []string) string {
|
||||
var rem uint32
|
||||
flags := make([]string, 0, bits.OnesCount32(v))
|
||||
for i := 0; i < 32 && v != 0; i++ {
|
||||
if bf := uint32(1 << i); v&bf != 0 {
|
||||
if name := flagNames[i]; name != "" {
|
||||
flags = append(flags, name)
|
||||
} else {
|
||||
rem |= bf
|
||||
}
|
||||
v &= ^bf
|
||||
}
|
||||
}
|
||||
if rem != 0 {
|
||||
flags = append(flags, "0x"+strings.ToUpper(strconv.FormatUint(uint64(rem), 16)))
|
||||
}
|
||||
return strings.Join(flags, "|")
|
||||
}
|
355
ipn/ipnauth/access_check.go
Normal file
355
ipn/ipnauth/access_check.go
Normal file
@ -0,0 +1,355 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// errNotAllowed is an error returned when access is neither explicitly allowed,
|
||||
// nor denied with a more specific error.
|
||||
var errNotAllowed error = ipn.NewAccessDeniedError("the requested operation is not allowed")
|
||||
|
||||
// AccessCheckResult represents the result of an access check.
|
||||
// Its zero value is valid and indicates that the access request
|
||||
// has neither been explicitly allowed nor denied for a specific reason.
|
||||
//
|
||||
// Higher-level access control code should forward the AccessCheckResult
|
||||
// from lower-level access control mechanisms to the caller
|
||||
// immediately upon receiving a definitive result, as indicated
|
||||
// by the AccessCheckResult.HasResult() method returning true.
|
||||
//
|
||||
// Requested access that has not been explicitly allowed
|
||||
// or explicitly denied is implicitly denied. This is reflected
|
||||
// in the values returned by AccessCheckResult's Allowed, Denied, and Error methods.
|
||||
type AccessCheckResult struct {
|
||||
err error
|
||||
hasResult bool
|
||||
}
|
||||
|
||||
// AllowAccess returns a new AccessCheckResult indicating that
|
||||
// the requested access has been allowed.
|
||||
//
|
||||
// Access control implementations should return AllowAccess()
|
||||
// only when they are certain that further access checks
|
||||
// are unnecessary and the requested access is definitively allowed.
|
||||
//
|
||||
// This includes cases where a certain access right, that might
|
||||
// otherwise be denied based on the environment and normal user rights,
|
||||
// is explicitly allowed by a corporate admin through syspolicy (GP or MDM).
|
||||
// It also covers situations where access is not denied by
|
||||
// higher-level access control mechanisms, such as syspolicy,
|
||||
// and is granted based on the user's identity, following
|
||||
// platform and environment-specific rules.
|
||||
// (e.g., because they are root on Unix or a profile owner on a personal Windows device).
|
||||
func AllowAccess() AccessCheckResult {
|
||||
return AccessCheckResult{hasResult: true}
|
||||
}
|
||||
|
||||
// DenyAccess returns a new AccessCheckResult indicating that
|
||||
// the requested access has been denied with the specified err.
|
||||
//
|
||||
// Access control implementations should return DenyAccess()
|
||||
// as soon as the requested access has been denied, without calling
|
||||
// any subsequent lower-level access checking mechanisms, if any.
|
||||
//
|
||||
// Higher-level access control code should forward the AccessCheckResult
|
||||
// from any lower-level access check to the caller as soon as it receives
|
||||
// a definitive result as indicated by the HasResult() method returning true.
|
||||
// Therefore, if access is denied due to tailscaled config or syspolicy settings,
|
||||
// it will be immediately denied, regardless of the caller's identity.
|
||||
func DenyAccess(err error) AccessCheckResult {
|
||||
if err == nil {
|
||||
err = ipn.NewInternalServerError("access denied with a nil error")
|
||||
} else {
|
||||
err = &ipn.AccessDeniedError{Err: err}
|
||||
}
|
||||
return AccessCheckResult{err: err, hasResult: true}
|
||||
}
|
||||
|
||||
// ContinueCheck returns a new AccessCheckResult indicating that
|
||||
// the requested access has neither been allowed, nor denied,
|
||||
// and any further access checks should be performed to determine the result.
|
||||
//
|
||||
// An an example, a higher level access control code that denies
|
||||
// certain access rights based on syspolicy may return ContinueCheck()
|
||||
// to indicate that access is not denied by any applicable policies,
|
||||
// and lower-level access checks should be performed.
|
||||
//
|
||||
// Similarly, if a tailscaled config file is present and restricts certain ipn.Prefs fields
|
||||
// from being modified, its access checking mechanism should return ContinueCheck()
|
||||
// when a user tries to change only preferences that are not locked down.
|
||||
//
|
||||
// As a general rule, any higher-level access checking code should
|
||||
// continue calling lower-level access checking code, until it either receives
|
||||
// and forwards a definitive result from one of the lower-level mechanisms,
|
||||
// or until there are no additional checks to be performed.
|
||||
// In the latter case, it can also return ContinueCheck(),
|
||||
// resulting in the requested access being implicitly denied.
|
||||
func ContinueCheck() AccessCheckResult {
|
||||
return AccessCheckResult{}
|
||||
}
|
||||
|
||||
// HasResult reports whether a definitive access decision (either allowed or denied) has been made.
|
||||
func (r AccessCheckResult) HasResult() bool {
|
||||
return r.hasResult
|
||||
}
|
||||
|
||||
// Allowed reports whether the requested access has been allowed.
|
||||
func (r AccessCheckResult) Allowed() bool {
|
||||
return r.hasResult && r.err == nil
|
||||
}
|
||||
|
||||
// Denied reports whether the requested access should be denied.
|
||||
func (r AccessCheckResult) Denied() bool {
|
||||
return !r.hasResult || r.err != nil
|
||||
}
|
||||
|
||||
// Error returns an ipn.AccessDeniedError detailing why access was denied,
|
||||
// or nil if access has been allowed.
|
||||
func (r AccessCheckResult) Error() error {
|
||||
if !r.hasResult && r.err == nil {
|
||||
return errNotAllowed
|
||||
}
|
||||
return r.err
|
||||
}
|
||||
|
||||
// String returns a string representation of r.
|
||||
func (r AccessCheckResult) String() string {
|
||||
switch {
|
||||
case !r.hasResult:
|
||||
return "Implicit Deny"
|
||||
case r.err != nil:
|
||||
return "Deny: " + r.err.Error()
|
||||
default:
|
||||
return "Allow"
|
||||
}
|
||||
}
|
||||
|
||||
// accessChecker is a helper type that allows step-by-step granting or denying of access rights.
|
||||
type accessChecker[T ~uint32] struct {
|
||||
remain T // access rights that were requested but have not been granted yet.
|
||||
res AccessCheckResult
|
||||
}
|
||||
|
||||
// newAccessChecker returns a new accessChecker with the specified requested access.
|
||||
func newAccessChecker[T ~uint32](requested T) accessChecker[T] {
|
||||
return accessChecker[T]{remain: requested}
|
||||
}
|
||||
|
||||
// remaining returns the access rights that have been requested but not yet granted.
|
||||
func (ac *accessChecker[T]) remaining() T {
|
||||
return ac.remain
|
||||
}
|
||||
|
||||
// result determines if access is Allowed, Denied, or requires further evaluation.
|
||||
func (ac *accessChecker[T]) result() AccessCheckResult {
|
||||
if !ac.res.HasResult() && ac.remaining() == 0 {
|
||||
ac.res = AllowAccess()
|
||||
}
|
||||
return ac.res
|
||||
}
|
||||
|
||||
// grant unconditionally grants the specified rights, updating and returning an AccessCheckResult.
|
||||
func (ac *accessChecker[T]) grant(rights T) AccessCheckResult {
|
||||
ac.remain &= ^rights
|
||||
return ac.result()
|
||||
}
|
||||
|
||||
// deny unconditionally denies the specified rights, updating and returning an AccessCheckResult.
|
||||
// If the specified rights were not requested, it is a no-op.
|
||||
func (ac *accessChecker[T]) deny(rights T, err error) AccessCheckResult {
|
||||
if ac.remain&rights != 0 {
|
||||
ac.res = DenyAccess(err)
|
||||
}
|
||||
return ac.result()
|
||||
}
|
||||
|
||||
// tryGrant grants the specified rights and updates the result if those rights have been requested
|
||||
// and the check does not return an error.
|
||||
// Otherwise, it is a no-op.
|
||||
func (ac *accessChecker[T]) tryGrant(rights T, check func() error) AccessCheckResult {
|
||||
if ac.remain&rights != 0 && check() == nil {
|
||||
return ac.grant(rights)
|
||||
}
|
||||
return ac.result()
|
||||
}
|
||||
|
||||
// mustGrant attempts to grant specified rights if they have been requested.
|
||||
// If the check fails with an error, that error is used as the reason for access denial.
|
||||
// If the specified rights were not requested, it is a no-op.
|
||||
func (ac *accessChecker[T]) mustGrant(rights T, check func() error) AccessCheckResult {
|
||||
if ac.remain&rights != 0 {
|
||||
if err := check(); err != nil {
|
||||
return ac.deny(rights, err)
|
||||
}
|
||||
return ac.grant(rights)
|
||||
}
|
||||
return ac.result()
|
||||
}
|
||||
|
||||
// CheckAccess reports whether the caller is allowed or denied the desired access.
|
||||
func CheckAccess(caller Identity, desired DeviceAccess) AccessCheckResult {
|
||||
// Allow non-user originating changes, such as any changes requested by the control plane.
|
||||
// We don't want these to be affected by GP/MDM policies or any other restrictions.
|
||||
if IsUnrestricted(caller) {
|
||||
return AllowAccess()
|
||||
}
|
||||
|
||||
// TODO(nickkhyl): check syspolicy.
|
||||
|
||||
return caller.CheckAccess(desired)
|
||||
}
|
||||
|
||||
// CheckProfileAccess reports whether the caller is allowed or denied the desired access
|
||||
// to a specific profile and its prefs.
|
||||
func CheckProfileAccess(caller Identity, profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
|
||||
// TODO(nickkhyl): consider moving or copying OperatorUser from ipn.Prefs to ipn.LoginProfile,
|
||||
// as this is the main reason why we need to read prefs here.
|
||||
|
||||
// Allow non-user originating changes, such as any changes requested by the control plane.
|
||||
// We don't want these to be affected by GP/MDM policies or any other restrictions.
|
||||
if IsUnrestricted(caller) {
|
||||
return AllowAccess()
|
||||
}
|
||||
|
||||
// TODO(nickkhyl): check syspolicy.
|
||||
|
||||
return caller.CheckProfileAccess(profile, prefs, requested)
|
||||
}
|
||||
|
||||
// CheckEditProfile reports whether the caller has access to apply the specified changes to
|
||||
// the profile and prefs.
|
||||
func CheckEditProfile(caller Identity, profile ipn.LoginProfileView, prefs ipn.PrefsGetter, changes *ipn.MaskedPrefs) AccessCheckResult {
|
||||
if IsUnrestricted(caller) {
|
||||
return AllowAccess()
|
||||
}
|
||||
|
||||
requiredAccess := PrefsChangeRequiredAccess(changes)
|
||||
return CheckProfileAccess(caller, profile, prefs, requiredAccess)
|
||||
}
|
||||
|
||||
// FilterProfile returns the specified profile, filtering or masking out fields
|
||||
// inaccessible to the caller. The provided profile value is considered immutable,
|
||||
// and a new instance of ipn.LoginProfile will be returned if any filtering is necessary.
|
||||
func FilterProfile(caller Identity, profile ipn.LoginProfileView, prefs ipn.PrefsGetter) ipn.LoginProfileView {
|
||||
switch {
|
||||
case CheckProfileAccess(caller, profile, prefs, ReadProfileInfo).Allowed():
|
||||
return profile
|
||||
default:
|
||||
res := &ipn.LoginProfile{
|
||||
ID: profile.ID(),
|
||||
Key: profile.Key(),
|
||||
LocalUserID: profile.LocalUserID(),
|
||||
UserProfile: maskedUserProfile(profile),
|
||||
NetworkProfile: maskedNetworkProfile(profile),
|
||||
}
|
||||
res.Name = res.UserProfile.LoginName
|
||||
return res.View()
|
||||
}
|
||||
}
|
||||
|
||||
// maskedNetworkProfile returns a masked tailcfg.UserProfile for the specified profile.
|
||||
// The returned value is used by ipnauth.FilterProfile in place of the actual ipn.LoginProfile.UserProfile
|
||||
// when the caller does not have ipnauth.ReadProfileInfo access to the profile.
|
||||
//
|
||||
// Although CLI or GUI clients can render this value as is, it's not localizable, may lead to a suboptimal UX,
|
||||
// and is provided mainly for compatibility with existing clients.
|
||||
//
|
||||
// For an improved UX, CLI and GUI clients should use UserProfile.ID.IsZero() to check
|
||||
// whether profile information is inaccessible and then render such profiles
|
||||
// in a platform-specific and localizable way.
|
||||
func maskedUserProfile(ipn.LoginProfileView) tailcfg.UserProfile {
|
||||
return tailcfg.UserProfile{
|
||||
LoginName: maskedLoginName,
|
||||
DisplayName: maskedDisplayName,
|
||||
ProfilePicURL: maskedProfilePicURL,
|
||||
}
|
||||
}
|
||||
|
||||
// maskedNetworkProfile returns a masked ipn.NetworkProfile for the specified profile.
|
||||
// It is like maskedUserProfile, but for NetworkProfile.
|
||||
func maskedNetworkProfile(ipn.LoginProfileView) ipn.NetworkProfile {
|
||||
return ipn.NetworkProfile{
|
||||
DomainName: maskedDomainName,
|
||||
}
|
||||
}
|
||||
|
||||
// PrefsChangeRequiredAccess returns the access required to change prefs as requested by mp.
|
||||
func PrefsChangeRequiredAccess(mp *ipn.MaskedPrefs) ProfileAccess {
|
||||
masked := reflect.ValueOf(mp).Elem()
|
||||
return maskedPrefsFieldsAccess(&mp.Prefs, "", masked)
|
||||
}
|
||||
|
||||
// maskedPrefsFieldsAccess returns the access required to change preferences, whose
|
||||
// corresponding {FieldName}Set flags are set in masked, to the values specified in p.
|
||||
// The `path` represents a dot-separated path to masked from the ipn.MaskedPrefs root.
|
||||
func maskedPrefsFieldsAccess(p *ipn.Prefs, path string, masked reflect.Value) ProfileAccess {
|
||||
var access ProfileAccess
|
||||
for i := 0; i < masked.NumField(); i++ {
|
||||
fName := masked.Type().Field(i).Name
|
||||
if !strings.HasSuffix(fName, "Set") {
|
||||
continue
|
||||
}
|
||||
fName = strings.TrimSuffix(fName, "Set")
|
||||
fPath := path + fName
|
||||
fValue := masked.Field(i)
|
||||
|
||||
switch fKind := fValue.Kind(); fKind {
|
||||
case reflect.Bool:
|
||||
if fValue.Bool() {
|
||||
access |= prefsFieldRequiredAccess(p, fPath)
|
||||
}
|
||||
case reflect.Struct:
|
||||
access |= maskedPrefsFieldsAccess(p, fPath+".", fValue)
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported mask field kind %v", fKind))
|
||||
}
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
// prefsDefaultFieldAccess is the default ProfileAccess required to modify ipn.Prefs fields
|
||||
// that do not have access rights overrides.
|
||||
const prefsDefaultFieldAccess = ChangePrefs
|
||||
|
||||
var (
|
||||
// prefsStaticFieldAccessOverride allows to override ProfileAccess needed to modify ipn.Prefs fields.
|
||||
// The map uses dot-separated field paths as keys.
|
||||
prefsStaticFieldAccessOverride = map[string]ProfileAccess{
|
||||
"ExitNodeID": ChangeExitNode,
|
||||
"ExitNodeIP": ChangeExitNode,
|
||||
"ExitNodeAllowLANAccess": ChangeExitNode,
|
||||
}
|
||||
// prefsDynamicFieldAccessOverride is like prefsStaticFieldAccessOverride, but it maps field paths
|
||||
// to functions that dynamically determine ProfileAccess based on the target value to be set.
|
||||
prefsDynamicFieldAccessOverride = map[string]func(p *ipn.Prefs) ProfileAccess{
|
||||
"WantRunning": prefsWantRunningRequiredAccess,
|
||||
}
|
||||
)
|
||||
|
||||
// prefsFieldRequiredAccess returns the access required to change a prefs field
|
||||
// represented by its field path in ipn.MaskedPrefs to the corresponding value in p.
|
||||
func prefsFieldRequiredAccess(p *ipn.Prefs, path string) ProfileAccess {
|
||||
if access, ok := prefsStaticFieldAccessOverride[path]; ok {
|
||||
return access
|
||||
}
|
||||
if accessFn, ok := prefsDynamicFieldAccessOverride[path]; ok {
|
||||
return accessFn(p)
|
||||
}
|
||||
return prefsDefaultFieldAccess
|
||||
}
|
||||
|
||||
// prefsWantRunningRequiredAccess returns the access required to change WantRunning to the value in p.
|
||||
func prefsWantRunningRequiredAccess(p *ipn.Prefs) ProfileAccess {
|
||||
if p.WantRunning {
|
||||
return Connect
|
||||
}
|
||||
return Disconnect
|
||||
}
|
550
ipn/ipnauth/access_check_test.go
Normal file
550
ipn/ipnauth/access_check_test.go
Normal file
@ -0,0 +1,550 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestAccessCheckResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
res AccessCheckResult
|
||||
wantStr string
|
||||
wantHasResult bool
|
||||
wantAllow bool
|
||||
wantDeny bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "zero-value-implicit-deny",
|
||||
res: AccessCheckResult{},
|
||||
wantStr: "Implicit Deny",
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "continue-implicit-deny",
|
||||
res: ContinueCheck(),
|
||||
wantStr: "Implicit Deny",
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "explicit-deny",
|
||||
res: DenyAccess(errNotAllowed),
|
||||
wantStr: "Deny: " + errNotAllowed.Error(),
|
||||
wantHasResult: true,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "explicit-allow",
|
||||
res: AllowAccess(),
|
||||
wantStr: "Allow",
|
||||
wantHasResult: true,
|
||||
wantAllow: true,
|
||||
wantDeny: false,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if gotStr := tt.res.String(); gotStr != tt.wantStr {
|
||||
t.Errorf("got: %q, want: %q", gotStr, tt.wantStr)
|
||||
}
|
||||
if gotHasResult := tt.res.HasResult(); gotHasResult != tt.wantHasResult {
|
||||
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
|
||||
}
|
||||
if gotAllow := tt.res.Allowed(); gotAllow != tt.wantAllow {
|
||||
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
|
||||
}
|
||||
if gotDeny := tt.res.Denied(); gotDeny != tt.wantDeny {
|
||||
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
|
||||
}
|
||||
|
||||
if gotErr := tt.res.Error(); tt.wantErr {
|
||||
if _, isAccessDenied := gotErr.(*ipn.AccessDeniedError); !isAccessDenied {
|
||||
t.Errorf("err: %v, wantErr: %v", gotErr, tt.wantErr)
|
||||
}
|
||||
} else if gotErr != nil {
|
||||
t.Errorf("err: %v, wantErr: %v", gotErr, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessCheckerGrant(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested ProfileAccess
|
||||
grant []ProfileAccess
|
||||
wantRemaining ProfileAccess
|
||||
wantHasResult bool
|
||||
wantAllow bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "grant-none",
|
||||
requested: ReadProfileInfo,
|
||||
grant: []ProfileAccess{},
|
||||
wantRemaining: ReadProfileInfo,
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "grant-single",
|
||||
requested: ReadProfileInfo,
|
||||
grant: []ProfileAccess{ReadProfileInfo},
|
||||
wantRemaining: 0,
|
||||
wantHasResult: true,
|
||||
wantAllow: true,
|
||||
wantDeny: false,
|
||||
},
|
||||
{
|
||||
name: "grant-other",
|
||||
requested: ReadProfileInfo,
|
||||
grant: []ProfileAccess{Connect},
|
||||
wantRemaining: ReadProfileInfo,
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "grant-some",
|
||||
requested: ReadProfileInfo | Connect,
|
||||
grant: []ProfileAccess{ReadProfileInfo},
|
||||
wantRemaining: Connect,
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "grant-all",
|
||||
requested: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
|
||||
grant: []ProfileAccess{ReadProfileInfo, Connect | Disconnect, ReadPrefs},
|
||||
wantRemaining: 0,
|
||||
wantHasResult: true,
|
||||
wantAllow: true,
|
||||
wantDeny: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checker := newAccessChecker(tt.requested)
|
||||
for _, grant := range tt.grant {
|
||||
checker.grant(grant)
|
||||
}
|
||||
if gotRemaining := checker.remaining(); gotRemaining != tt.wantRemaining {
|
||||
t.Errorf("gotRemaining: %v, wantRemaining: %v", gotRemaining, tt.wantRemaining)
|
||||
}
|
||||
res := checker.result()
|
||||
if gotHasResult := res.HasResult(); gotHasResult != tt.wantHasResult {
|
||||
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
|
||||
}
|
||||
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
|
||||
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
|
||||
}
|
||||
if gotDeny := res.Denied(); gotDeny != tt.wantDeny {
|
||||
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessCheckerConditionalGrant(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested ProfileAccess
|
||||
mustGrant bool
|
||||
grant ProfileAccess
|
||||
predicate func() error
|
||||
wantRemaining ProfileAccess
|
||||
wantHasResult bool
|
||||
wantAllow bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "try-grant",
|
||||
requested: ReadProfileInfo,
|
||||
grant: ReadProfileInfo,
|
||||
predicate: func() error { return nil },
|
||||
wantRemaining: 0,
|
||||
wantHasResult: true,
|
||||
wantAllow: true,
|
||||
wantDeny: false,
|
||||
},
|
||||
{
|
||||
name: "try-grant-err",
|
||||
requested: ReadProfileInfo,
|
||||
grant: ReadProfileInfo,
|
||||
predicate: func() error { return errNotAllowed },
|
||||
wantRemaining: ReadProfileInfo,
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "must-grant",
|
||||
requested: ReadProfileInfo,
|
||||
mustGrant: true,
|
||||
grant: ReadProfileInfo,
|
||||
predicate: func() error { return nil },
|
||||
wantRemaining: 0,
|
||||
wantHasResult: true,
|
||||
wantAllow: true,
|
||||
wantDeny: false,
|
||||
},
|
||||
{
|
||||
name: "must-grant-err",
|
||||
requested: ReadProfileInfo,
|
||||
mustGrant: true,
|
||||
grant: ReadProfileInfo,
|
||||
predicate: func() error { return errNotAllowed },
|
||||
wantRemaining: ReadProfileInfo,
|
||||
wantHasResult: true,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checker := newAccessChecker(tt.requested)
|
||||
|
||||
var res AccessCheckResult
|
||||
if tt.mustGrant {
|
||||
res = checker.mustGrant(tt.grant, tt.predicate)
|
||||
} else {
|
||||
res = checker.tryGrant(tt.grant, tt.predicate)
|
||||
}
|
||||
|
||||
if gotRemaining := checker.remaining(); gotRemaining != tt.wantRemaining {
|
||||
t.Errorf("gotRemaining: %v, wantRemaining: %v", gotRemaining, tt.wantRemaining)
|
||||
}
|
||||
if gotHasResult := res.HasResult(); gotHasResult != tt.wantHasResult {
|
||||
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
|
||||
}
|
||||
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
|
||||
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
|
||||
}
|
||||
if gotDeny := res.Denied(); gotDeny != tt.wantDeny {
|
||||
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessCheckerDeny(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested ProfileAccess
|
||||
grant ProfileAccess
|
||||
deny ProfileAccess
|
||||
wantHasResult bool
|
||||
wantAllow bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "deny-single",
|
||||
requested: ReadProfileInfo,
|
||||
deny: ReadProfileInfo,
|
||||
wantHasResult: true,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "deny-other",
|
||||
requested: ReadProfileInfo,
|
||||
deny: Connect,
|
||||
wantHasResult: false,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "grant-some-then-deny",
|
||||
requested: ReadProfileInfo | Connect,
|
||||
grant: ReadProfileInfo,
|
||||
deny: Connect,
|
||||
wantHasResult: true,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "deny-some",
|
||||
requested: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
|
||||
deny: Connect,
|
||||
wantHasResult: true,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "deny-all",
|
||||
requested: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
|
||||
deny: ReadProfileInfo | Connect | Disconnect | ReadPrefs,
|
||||
wantHasResult: true,
|
||||
wantAllow: false,
|
||||
wantDeny: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checker := newAccessChecker(tt.requested)
|
||||
res := checker.grant(tt.grant)
|
||||
if res.HasResult() {
|
||||
t.Fatalf("the result must not be ready yet")
|
||||
}
|
||||
res = checker.deny(tt.deny, errNotAllowed)
|
||||
if gotHasResult := res.HasResult(); gotHasResult != tt.wantHasResult {
|
||||
t.Errorf("gotHasResult: %v, wantHasResult: %v", gotHasResult, tt.wantHasResult)
|
||||
}
|
||||
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
|
||||
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
|
||||
}
|
||||
if gotDeny := res.Denied(); gotDeny != tt.wantDeny {
|
||||
t.Errorf("gotDeny: %v, wantDeny: %v", gotDeny, tt.wantDeny)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterProfile(t *testing.T) {
|
||||
profile := &ipn.LoginProfile{
|
||||
ID: "TEST",
|
||||
Key: "profile-TEST",
|
||||
Name: "user@example.com",
|
||||
NetworkProfile: ipn.NetworkProfile{
|
||||
MagicDNSName: "example.ts.net",
|
||||
DomainName: "example.ts.net",
|
||||
},
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
ID: 123456789,
|
||||
LoginName: "user@example.com",
|
||||
DisplayName: "User",
|
||||
ProfilePicURL: "https://example.com/profile.png",
|
||||
},
|
||||
NodeID: "TEST-NODE-ID",
|
||||
LocalUserID: "S-1-5-21-1234567890-1234567890-1234567890-1001",
|
||||
ControlURL: "https://controlplane.tailscale.com",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
user Identity
|
||||
profile *ipn.LoginProfile
|
||||
wantProfile *ipn.LoginProfile
|
||||
}{
|
||||
{
|
||||
name: "filter-unreadable",
|
||||
user: &TestIdentity{ProfileAccess: 0},
|
||||
profile: profile,
|
||||
wantProfile: &ipn.LoginProfile{
|
||||
ID: profile.ID,
|
||||
Name: "Other User's Account",
|
||||
Key: profile.Key,
|
||||
LocalUserID: profile.LocalUserID,
|
||||
UserProfile: tailcfg.UserProfile{
|
||||
LoginName: "Other User's Account",
|
||||
DisplayName: "Other User",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "do-not-filter-readable",
|
||||
user: &TestIdentity{UID: string(profile.LocalUserID), ProfileAccess: ReadProfileInfo},
|
||||
profile: profile,
|
||||
wantProfile: profile,
|
||||
},
|
||||
{
|
||||
name: "do-not-filter-for-self",
|
||||
user: Self,
|
||||
profile: profile,
|
||||
wantProfile: profile,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := FilterProfile(tt.user, tt.profile.View(), ipn.PrefsGetterFor(ipn.PrefsView{})).AsStruct()
|
||||
if !reflect.DeepEqual(profile, tt.wantProfile) {
|
||||
t.Errorf("got: %+v, want: %+v", profile, tt.wantProfile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefsChangeRequiredAccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefs ipn.MaskedPrefs
|
||||
wantRequiredAccess ProfileAccess
|
||||
}{
|
||||
{
|
||||
name: "no-changes",
|
||||
prefs: ipn.MaskedPrefs{},
|
||||
wantRequiredAccess: 0,
|
||||
},
|
||||
{
|
||||
name: "connect",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
WantRunningSet: true,
|
||||
},
|
||||
wantRequiredAccess: Connect,
|
||||
},
|
||||
{
|
||||
name: "disconnect",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: false},
|
||||
WantRunningSet: true,
|
||||
},
|
||||
wantRequiredAccess: Disconnect,
|
||||
},
|
||||
{
|
||||
name: "change-exit-node-id",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ExitNodeIDSet: true,
|
||||
},
|
||||
wantRequiredAccess: ChangeExitNode,
|
||||
},
|
||||
{
|
||||
name: "change-exit-node-ip",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ExitNodeIPSet: true,
|
||||
},
|
||||
wantRequiredAccess: ChangeExitNode,
|
||||
},
|
||||
{
|
||||
name: "change-exit-node-lan-access",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ExitNodeAllowLANAccessSet: true,
|
||||
},
|
||||
wantRequiredAccess: ChangeExitNode,
|
||||
},
|
||||
{
|
||||
name: "change-multiple",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
ExitNodeIDSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
wantRequiredAccess: Connect | ChangeExitNode,
|
||||
},
|
||||
{
|
||||
name: "change-other-single",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ForceDaemonSet: true,
|
||||
},
|
||||
wantRequiredAccess: ChangePrefs,
|
||||
},
|
||||
{
|
||||
name: "change-other-multiple",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ForceDaemonSet: true,
|
||||
RunSSHSet: true,
|
||||
},
|
||||
wantRequiredAccess: ChangePrefs,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotRequiredAccess := PrefsChangeRequiredAccess(&tt.prefs)
|
||||
if gotRequiredAccess != tt.wantRequiredAccess {
|
||||
t.Errorf("got: %v, want: %v", gotRequiredAccess, tt.wantRequiredAccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEditProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefs ipn.MaskedPrefs
|
||||
user Identity
|
||||
wantAllow bool
|
||||
}{
|
||||
{
|
||||
name: "allow-connect",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
WantRunningSet: true,
|
||||
},
|
||||
user: &TestIdentity{ProfileAccess: Connect},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-connect",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
WantRunningSet: true,
|
||||
},
|
||||
user: &TestIdentity{ProfileAccess: ReadProfileInfo},
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-change-exit-node",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ExitNodeIDSet: true,
|
||||
},
|
||||
user: &TestIdentity{ProfileAccess: ChangeExitNode},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-change-prefs",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ForceDaemonSet: true,
|
||||
RunSSHSet: true,
|
||||
},
|
||||
user: &TestIdentity{ProfileAccess: ChangePrefs},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-change-prefs",
|
||||
prefs: ipn.MaskedPrefs{
|
||||
ForceDaemonSet: true,
|
||||
RunSSHSet: true,
|
||||
},
|
||||
user: &TestIdentity{ProfileAccess: ChangeExitNode},
|
||||
wantAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile, prefs := ipn.LoginProfile{}, ipn.NewPrefs()
|
||||
res := CheckEditProfile(tt.user, profile.View(), ipn.PrefsGetterFor(prefs.View()), &tt.prefs)
|
||||
if gotAllow := res.Allowed(); gotAllow != tt.wantAllow {
|
||||
t.Errorf("gotAllow: %v, wantAllow: %v", gotAllow, tt.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyAccessWithNilError(t *testing.T) {
|
||||
res := DenyAccess(nil)
|
||||
if gotHasResult := res.HasResult(); !gotHasResult {
|
||||
t.Errorf("gotHasResult: %v, wantHasResult: true", gotHasResult)
|
||||
}
|
||||
if gotAllow := res.Allowed(); gotAllow {
|
||||
t.Errorf("gotAllow: %v, wantAllow: false", gotAllow)
|
||||
}
|
||||
if gotDeny := res.Denied(); !gotDeny {
|
||||
t.Errorf("gotDeny: %v, wantDeny: true", gotDeny)
|
||||
}
|
||||
gotErr := res.Error()
|
||||
if _, isInternalError := gotErr.(*ipn.InternalServerError); !isInternalError {
|
||||
t.Errorf("got %T: %v, want: *ipn.InternalServerError", gotErr, gotErr)
|
||||
}
|
||||
}
|
118
ipn/ipnauth/access_test.go
Normal file
118
ipn/ipnauth/access_test.go
Normal file
@ -0,0 +1,118 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceAccessStringer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
access DeviceAccess
|
||||
wantStr string
|
||||
}{
|
||||
{
|
||||
name: "zero-access",
|
||||
access: 0,
|
||||
wantStr: "(None)",
|
||||
},
|
||||
{
|
||||
name: "unrestricted-access",
|
||||
access: ^DeviceAccess(0),
|
||||
wantStr: "(Unrestricted)",
|
||||
},
|
||||
{
|
||||
name: "single-access",
|
||||
access: ReadDeviceStatus,
|
||||
wantStr: "ReadDeviceStatus",
|
||||
},
|
||||
{
|
||||
name: "multi-access",
|
||||
access: ReadDeviceStatus | GenerateBugReport | DeleteAllProfiles,
|
||||
wantStr: "ReadDeviceStatus|GenerateBugReport|DeleteAllProfiles",
|
||||
},
|
||||
{
|
||||
name: "unknown-access",
|
||||
access: DeviceAccess(0xABCD0000),
|
||||
wantStr: "0xABCD0000",
|
||||
},
|
||||
{
|
||||
name: "multi-with-unknown-access",
|
||||
access: ReadDeviceStatus | DeviceAccess(0xABCD0000),
|
||||
wantStr: "ReadDeviceStatus|0xABCD0000",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotStr := tt.access.String()
|
||||
if gotStr != tt.wantStr {
|
||||
t.Errorf("got %v, want %v", gotStr, tt.wantStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccessStringer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
access ProfileAccess
|
||||
wantStr string
|
||||
}{
|
||||
{
|
||||
name: "zero-access",
|
||||
access: 0,
|
||||
wantStr: "(None)",
|
||||
},
|
||||
{
|
||||
name: "unrestricted-access",
|
||||
access: ^ProfileAccess(0),
|
||||
wantStr: "(Unrestricted)",
|
||||
},
|
||||
{
|
||||
name: "single-access",
|
||||
access: ReadProfileInfo,
|
||||
wantStr: "ReadProfileInfo",
|
||||
},
|
||||
{
|
||||
name: "multi-access",
|
||||
access: ReadProfileInfo | Connect | Disconnect,
|
||||
wantStr: "ReadProfileInfo|Connect|Disconnect",
|
||||
},
|
||||
{
|
||||
name: "unknown-access",
|
||||
access: ProfileAccess(0xFF000000),
|
||||
wantStr: "0xFF000000",
|
||||
},
|
||||
{
|
||||
name: "multi-with-unknown-access",
|
||||
access: ReadProfileInfo | ProfileAccess(0xFF000000),
|
||||
wantStr: "ReadProfileInfo|0xFF000000",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotStr := tt.access.String()
|
||||
if gotStr != tt.wantStr {
|
||||
t.Errorf("got %v, want %v", gotStr, tt.wantStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedDeviceAccessFlagsArePowerOfTwo(t *testing.T) {
|
||||
for da, name := range deviceAccessNames {
|
||||
if (da & (da - 1)) != 0 {
|
||||
t.Errorf("DeviceAccess, %s: got 0x%x, want power of two", name, uint64(da))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedProfileAccessFlagsArePowerOfTwo(t *testing.T) {
|
||||
for pa, name := range profileAccessNames {
|
||||
if (pa & (pa - 1)) != 0 {
|
||||
t.Errorf("ProfileAccess, %s: got 0x%x, want power of two", name, uint64(pa))
|
||||
}
|
||||
}
|
||||
}
|
73
ipn/ipnauth/identity.go
Normal file
73
ipn/ipnauth/identity.go
Normal file
@ -0,0 +1,73 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Identity is any caller identity.
|
||||
//
|
||||
// It typically represents a specific OS user, indicating that an operation
|
||||
// is performed on behalf of this user, should be evaluated against their
|
||||
// access rights, and performed in their security context when applicable.
|
||||
//
|
||||
// However, it can also represent an unrestricted identity (e.g. ipnauth.Self) when an operation
|
||||
// is executed on behalf of tailscaled itself, in response to a control plane request,
|
||||
// or when a user's access rights have been verified via other means.
|
||||
type Identity interface {
|
||||
// UserID returns an OS-specific UID of the user represented by the identity,
|
||||
// or "" if the receiver does not represent a specific user.
|
||||
// As of 2024-04-08, it is only used on Windows.
|
||||
UserID() ipn.WindowsUserID
|
||||
// Username returns the user name associated with the receiver,
|
||||
// or "" if the receiver does not represent a specific user.
|
||||
Username() (string, error)
|
||||
// CheckAccess reports whether the receiver is allowed or denied the requested device access.
|
||||
//
|
||||
// This method ignores environment factors, Group Policy, and MDM settings that might
|
||||
// override access permissions at a higher level than individual user identities.
|
||||
// Therefore, most callers should use ipnauth.CheckAccess instead.
|
||||
CheckAccess(requested DeviceAccess) AccessCheckResult
|
||||
// CheckProfileAccess reports whether the receiver is allowed or denied the requested access
|
||||
// to a specific profile and its prefs.
|
||||
//
|
||||
// This method ignores environment factors, Group Policy, and MDM settings that might
|
||||
// override access permissions at a higher level than individual user identities.
|
||||
// Therefore, most callers should use ipnauth.CheckProfileAccess instead.
|
||||
CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult
|
||||
}
|
||||
|
||||
type identityContextKey struct{}
|
||||
|
||||
var errNoSecContext = ipn.NewAccessDeniedError("security context not available")
|
||||
|
||||
// RequestIdentity returns a user identity associated with ctx,
|
||||
// or an error if the context does not carry a user's identity.
|
||||
func RequestIdentity(ctx context.Context) (Identity, error) {
|
||||
switch v := ctx.Value(identityContextKey{}).(type) {
|
||||
case Identity:
|
||||
return v, nil
|
||||
case error:
|
||||
return nil, v
|
||||
case nil:
|
||||
return nil, errNoSecContext
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
// ContextWithConnIdentity returns a new context that carries the identity of the user
|
||||
// owning the other end of the connection.
|
||||
func ContextWithConnIdentity(ctx context.Context, logf logger.Logf, c net.Conn) context.Context {
|
||||
ci, err := GetConnIdentity(logf, c)
|
||||
if err != nil {
|
||||
return context.WithValue(ctx, identityContextKey{}, err)
|
||||
}
|
||||
return context.WithValue(ctx, identityContextKey{}, ci)
|
||||
}
|
102
ipn/ipnauth/identity_test.go
Normal file
102
ipn/ipnauth/identity_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
var allGOOSes = []string{"linux", "darwin", "windows", "freebsd"}
|
||||
|
||||
type accessTest[Access ~uint32] struct {
|
||||
name string
|
||||
geese []string
|
||||
requestAccess []Access
|
||||
isLocalAdmin bool
|
||||
wantAllow bool
|
||||
}
|
||||
|
||||
func TestServeAccess(t *testing.T) {
|
||||
tests := []accessTest[ProfileAccess]{
|
||||
{
|
||||
name: "read-serve-not-admin",
|
||||
geese: allGOOSes,
|
||||
requestAccess: []ProfileAccess{ReadServe},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "change-serve-not-admin",
|
||||
geese: []string{"windows"},
|
||||
requestAccess: []ProfileAccess{ChangeServe},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "change-serve-not-admin",
|
||||
geese: []string{"linux", "darwin", "freebsd"},
|
||||
requestAccess: []ProfileAccess{ChangeServe},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "serve-path-not-admin",
|
||||
geese: allGOOSes,
|
||||
requestAccess: []ProfileAccess{ServePath},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "serve-path-admin",
|
||||
geese: allGOOSes,
|
||||
requestAccess: []ProfileAccess{ServePath},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
}
|
||||
runProfileAccessTests(t, tests)
|
||||
}
|
||||
|
||||
func runDeviceAccessTests(t *testing.T, tests []accessTest[DeviceAccess]) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, goos := range tt.geese {
|
||||
user := NewTestIdentityWithGOOS(goos, "test", tt.isLocalAdmin)
|
||||
for _, access := range tt.requestAccess {
|
||||
testName := goos + "-" + tt.name + "-" + access.String()
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
res := user.CheckAccess(access)
|
||||
if res.Allowed() != tt.wantAllow {
|
||||
t.Errorf("got result = %v, want allow %v", res, tt.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runProfileAccessTests(t *testing.T, tests []accessTest[ProfileAccess]) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, goos := range tt.geese {
|
||||
user := NewTestIdentityWithGOOS(goos, "test", tt.isLocalAdmin)
|
||||
profile := &ipn.LoginProfile{LocalUserID: user.UserID()}
|
||||
prefs := func() (ipn.PrefsView, error) { return ipn.NewPrefs().View(), nil }
|
||||
|
||||
for _, access := range tt.requestAccess {
|
||||
testName := goos + "-" + tt.name + "-" + access.String()
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
res := user.CheckProfileAccess(profile.View(), prefs, access)
|
||||
if res.Allowed() != tt.wantAllow {
|
||||
t.Errorf("got result = %v, want allow %v", res, tt.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -9,9 +9,19 @@
|
||||
"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.
|
||||
|
@ -14,8 +14,30 @@
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/winutil/winenv"
|
||||
)
|
||||
|
||||
// GetConnIdentity extracts the identity information from the connection
|
||||
// based on the user who owns the other end of the connection.
|
||||
// If c is not backed by a named pipe, an error is returned.
|
||||
// TODO(nickkhyl): rename this to GetConnIdentity once we no longer need
|
||||
// the original GetConnIdentity.
|
||||
func GetIdentity(c net.Conn) (ci Identity, err error) {
|
||||
wcc, ok := c.(*safesocket.WindowsClientConn)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not a WindowsClientConn: %T", c)
|
||||
}
|
||||
tok, err := windowsClientToken(wcc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newWindowsIdentity(tok, currentWindowsEnvironment()), nil
|
||||
}
|
||||
|
||||
func currentWindowsEnvironment() WindowsEnvironment {
|
||||
return WindowsEnvironment{IsManaged: winenv.IsManaged(), IsServer: winenv.IsWindowsServer()}
|
||||
}
|
||||
|
||||
// GetConnIdentity extracts the identity information from the connection
|
||||
// based on the user who owns the other end of the connection.
|
||||
// If c is not backed by a named pipe, an error is returned.
|
||||
@ -168,7 +190,12 @@ func (ci *ConnIdentity) WindowsToken() (WindowsToken, error) {
|
||||
if wcc, ok = ci.conn.(*safesocket.WindowsClientConn); !ok {
|
||||
return nil, fmt.Errorf("not a WindowsClientConn: %T", ci.conn)
|
||||
}
|
||||
return windowsClientToken(wcc)
|
||||
}
|
||||
|
||||
// windowsClientToken returns the WindowsToken representing the security context
|
||||
// of the connection's client.
|
||||
func windowsClientToken(wcc *safesocket.WindowsClientConn) (WindowsToken, error) {
|
||||
// We duplicate the token's handle so that the WindowsToken we return may have
|
||||
// a lifetime independent from the original connection.
|
||||
var h windows.Handle
|
||||
|
155
ipn/ipnauth/nonwin_test.go
Normal file
155
ipn/ipnauth/nonwin_test.go
Normal file
@ -0,0 +1,155 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
var (
|
||||
unixGOOSes = []string{"linux", "darwin", "freebsd"}
|
||||
otherGOOSes = []string{"js"}
|
||||
)
|
||||
|
||||
func TestDeviceAccessUnix(t *testing.T) {
|
||||
tests := []accessTest[DeviceAccess]{
|
||||
{
|
||||
name: "allow-read-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []DeviceAccess{ReadDeviceStatus, GenerateBugReport},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-read-non-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []DeviceAccess{ReadDeviceStatus, GenerateBugReport},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-non-read-non-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []DeviceAccess{^(ReadDeviceStatus | GenerateBugReport)},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-all-access-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
}
|
||||
runDeviceAccessTests(t, tests)
|
||||
}
|
||||
|
||||
func TestDeviceAccessOther(t *testing.T) {
|
||||
tests := []accessTest[DeviceAccess]{
|
||||
{
|
||||
name: "allow-all-access-admin",
|
||||
geese: otherGOOSes,
|
||||
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-all-access-non-admin",
|
||||
geese: otherGOOSes,
|
||||
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: true,
|
||||
},
|
||||
}
|
||||
runDeviceAccessTests(t, tests)
|
||||
}
|
||||
|
||||
func TestProfileAccessUnix(t *testing.T) {
|
||||
tests := []accessTest[ProfileAccess]{
|
||||
{
|
||||
name: "allow-read-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, ReadPrefs, ReadServe, ListPeers},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-read-non-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, ReadPrefs, ReadServe, ListPeers},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-non-read-non-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []ProfileAccess{^(ReadProfileInfo | ReadPrefs | ReadServe | ListPeers)},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-use-profile-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []ProfileAccess{Connect, Disconnect, DeleteProfile, ReauthProfile, ChangePrefs, ChangeExitNode},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-use-profile-non-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []ProfileAccess{Connect, Disconnect, DeleteProfile, ReauthProfile, ChangePrefs, ChangeExitNode},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-all-access-admin",
|
||||
geese: unixGOOSes,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
}
|
||||
runProfileAccessTests(t, tests)
|
||||
}
|
||||
|
||||
func TestFetchCertsAccessUnix(t *testing.T) {
|
||||
for _, goos := range unixGOOSes {
|
||||
t.Run(goos, func(t *testing.T) {
|
||||
user := NewTestIdentityWithGOOS(goos, "user", false)
|
||||
uid := *user.(*unixIdentity).forceForTest.uid
|
||||
envknob.Setenv("TS_PERMIT_CERT_UID", uid)
|
||||
defer envknob.Setenv("TS_PERMIT_CERT_UID", "")
|
||||
|
||||
profile := ipn.LoginProfile{}
|
||||
res := user.CheckProfileAccess(profile.View(), ipn.PrefsGetterFor(ipn.NewPrefs().View()), FetchCerts)
|
||||
if !res.Allowed() {
|
||||
t.Errorf("got result = %v, want allow", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccessOther(t *testing.T) {
|
||||
tests := []accessTest[ProfileAccess]{
|
||||
{
|
||||
name: "allow-all-access-admin",
|
||||
geese: otherGOOSes,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess},
|
||||
isLocalAdmin: true,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-all-access-non-admin",
|
||||
geese: otherGOOSes,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess},
|
||||
isLocalAdmin: false,
|
||||
wantAllow: true,
|
||||
},
|
||||
}
|
||||
runProfileAccessTests(t, tests)
|
||||
}
|
45
ipn/ipnauth/self.go
Normal file
45
ipn/ipnauth/self.go
Normal file
@ -0,0 +1,45 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import "tailscale.com/ipn"
|
||||
|
||||
// Self is a caller identity that represents the tailscaled itself and therefore has unlimited access.
|
||||
//
|
||||
// It's typically used for operations performed by tailscaled on its own,
|
||||
// or upon a request from the control plane, rather on behalf of a specific user.
|
||||
var Self Identity = unrestricted{}
|
||||
|
||||
// IsUnrestricted reports whether the specified identity has unrestricted access to the LocalBackend,
|
||||
// including all user profiles and preferences, serving as a performance optimization
|
||||
// and ensuring that tailscaled operates correctly, unaffected by Group Policy, MDM, or similar restrictions.
|
||||
func IsUnrestricted(identity Identity) bool {
|
||||
if _, ok := identity.(unrestricted); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type unrestricted struct {
|
||||
}
|
||||
|
||||
// UserID returns an empty string.
|
||||
func (unrestricted) UserID() ipn.WindowsUserID {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Username returns an empty string.
|
||||
func (unrestricted) Username() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// CheckAccess always allows the requested access.
|
||||
func (unrestricted) CheckAccess(desired DeviceAccess) AccessCheckResult {
|
||||
return AllowAccess()
|
||||
}
|
||||
|
||||
// CheckProfileAccess always allows the requested profile access.
|
||||
func (unrestricted) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
|
||||
return AllowAccess()
|
||||
}
|
162
ipn/ipnauth/testutils.go
Normal file
162
ipn/ipnauth/testutils.go
Normal file
@ -0,0 +1,162 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
// TestIdentity is an identity with a predefined UID, Name and access rights.
|
||||
// It should only be used for testing purposes, and allows external packages
|
||||
// to test against a specific set of access rights.
|
||||
type TestIdentity struct {
|
||||
UID string // UID is an OS-specific user id of the test user.
|
||||
Name string // Name is the login name of the test user.
|
||||
DeviceAccess DeviceAccess // DeviceAccess is the test user's access rights on the device.
|
||||
ProfileAccess ProfileAccess // ProfileAccess is the test user's access rights to Tailscale profiles.
|
||||
AccessOthersProfiles bool // AccessOthersProfiles indicates whether the test user can access all profiles, regardless of their ownership.
|
||||
}
|
||||
|
||||
var (
|
||||
// TestAdmin is a test identity that has unrestricted access to the device
|
||||
// and all Tailscale profiles on it. It should only be used for testing purposes.
|
||||
TestAdmin = &TestIdentity{
|
||||
Name: "admin",
|
||||
DeviceAccess: UnrestrictedDeviceAccess,
|
||||
ProfileAccess: UnrestrictedProfileAccess,
|
||||
AccessOthersProfiles: true,
|
||||
}
|
||||
)
|
||||
|
||||
// NewTestIdentityWithGOOS returns a new test identity for the given GOOS,
|
||||
// with the specified user name and the isAdmin flag indicating
|
||||
// whether the user has administrative access on the local machine.
|
||||
//
|
||||
// When goos is windows, it returns an identity representing an elevated admin
|
||||
// or a regular user account on a non-managed non-server environment. Callers
|
||||
// that require fine-grained control over user's privileges or environment
|
||||
// should use NewWindowsIdentity instead.
|
||||
func NewTestIdentityWithGOOS(goos, name string, isAdmin bool) Identity {
|
||||
if goos == "windows" {
|
||||
token := &testToken{
|
||||
SID: ipn.WindowsUserID(name),
|
||||
Name: name,
|
||||
Admin: isAdmin,
|
||||
Elevated: isAdmin,
|
||||
}
|
||||
return newWindowsIdentity(token, WindowsEnvironment{})
|
||||
}
|
||||
identity := &unixIdentity{goos: goos}
|
||||
identity.forceForTest.username = ptr.To(name)
|
||||
identity.forceForTest.isAdmin = ptr.To(isAdmin)
|
||||
if isAdmin {
|
||||
identity.forceForTest.uid = ptr.To("0")
|
||||
} else {
|
||||
identity.forceForTest.uid = ptr.To("1000")
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// NewTestIdentity is like NewTestIdentityWithGOOS, but returns a test identity
|
||||
// for the current platform.
|
||||
func NewTestIdentity(name string, isAdmin bool) Identity {
|
||||
return NewTestIdentityWithGOOS(runtime.GOOS, name, isAdmin)
|
||||
}
|
||||
|
||||
// UserID returns t.ID.
|
||||
func (t *TestIdentity) UserID() ipn.WindowsUserID {
|
||||
return ipn.WindowsUserID(t.UID)
|
||||
}
|
||||
|
||||
// Username returns t.Name.
|
||||
func (t *TestIdentity) Username() (string, error) {
|
||||
return t.Name, nil
|
||||
}
|
||||
|
||||
// CheckAccess reports whether the requested access is allowed or denied
|
||||
// based on t.DeviceAccess.
|
||||
func (t *TestIdentity) CheckAccess(requested DeviceAccess) AccessCheckResult {
|
||||
if requested&t.DeviceAccess == requested {
|
||||
return AllowAccess()
|
||||
}
|
||||
return DenyAccess(errors.New("access denied"))
|
||||
}
|
||||
|
||||
// CheckProfileAccess reports whether the requested profile access is allowed or denied
|
||||
// based on t.ProfileAccess.
|
||||
func (t *TestIdentity) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
|
||||
if !t.AccessOthersProfiles && profile.LocalUserID() != t.UserID() && profile.LocalUserID() != "" {
|
||||
return DenyAccess(errors.New("the requested profile is owned by another user"))
|
||||
}
|
||||
if t.ProfileAccess&requested == requested {
|
||||
return AllowAccess()
|
||||
}
|
||||
return DenyAccess(errors.New("access denied"))
|
||||
}
|
||||
|
||||
// testToken implements WindowsToken and should only be used for testing purposes.
|
||||
type testToken struct {
|
||||
SID ipn.WindowsUserID
|
||||
Name string
|
||||
Admin, Elevated bool
|
||||
LocalSystem bool
|
||||
}
|
||||
|
||||
// UID returns t's Security Identifier (SID).
|
||||
func (t *testToken) UID() (ipn.WindowsUserID, error) {
|
||||
return t.SID, nil
|
||||
}
|
||||
|
||||
// Username returns t's username.
|
||||
func (t *testToken) Username() (string, error) {
|
||||
return t.Name, nil
|
||||
}
|
||||
|
||||
// IsAdministrator reports whether t represents an admin's,
|
||||
// but not necessarily elevated, security context.
|
||||
func (t *testToken) IsAdministrator() (bool, error) {
|
||||
return t.Admin, nil
|
||||
}
|
||||
|
||||
// IsElevated reports whether t represents an elevated security context,
|
||||
// such as of LocalSystem or "Run as administrator".
|
||||
func (t *testToken) IsElevated() bool {
|
||||
return t.Elevated || t.IsLocalSystem()
|
||||
}
|
||||
|
||||
// IsLocalSystem reports whether t represents a LocalSystem's security context.
|
||||
func (t *testToken) IsLocalSystem() bool {
|
||||
return t.LocalSystem
|
||||
}
|
||||
|
||||
// UserDir is not implemented.
|
||||
func (t *testToken) UserDir(folderID string) (string, error) {
|
||||
return "", errors.New("Not implemented")
|
||||
}
|
||||
|
||||
// Close is a no-op.
|
||||
func (t *testToken) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EqualUIDs reports whether two WindowsTokens have the same UIDs.
|
||||
func (t *testToken) EqualUIDs(other WindowsToken) bool {
|
||||
if t != nil && other == nil || t == nil && other != nil {
|
||||
return false
|
||||
}
|
||||
ot, ok := other.(*testToken)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return t == ot || t.SID == ot.SID
|
||||
}
|
||||
|
||||
// IsUID reports whether t has the specified UID.
|
||||
func (t *testToken) IsUID(uid ipn.WindowsUserID) bool {
|
||||
return t.SID == uid
|
||||
}
|
322
ipn/ipnauth/unix.go
Normal file
322
ipn/ipnauth/unix.go
Normal file
@ -0,0 +1,322 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tailscale/peercred"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/groupmember"
|
||||
"tailscale.com/util/osuser"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
var (
|
||||
errMustBeRootOrOperator = ipn.NewAccessDeniedError("must be root or an operator")
|
||||
errMustBeRootOrSudoerOperator = ipn.NewAccessDeniedError("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
|
||||
)
|
||||
|
||||
var _ Identity = (*unixIdentity)(nil)
|
||||
|
||||
// unixIdentity is a non-Windows user identity.
|
||||
type unixIdentity struct {
|
||||
goos string
|
||||
creds *peercred.Creds // or nil
|
||||
|
||||
// forceForTest are fields used exclusively for testing purposes.
|
||||
// Only non-nil values within this struct are used.
|
||||
forceForTest struct {
|
||||
uid, username *string
|
||||
isAdmin *bool
|
||||
}
|
||||
}
|
||||
|
||||
// UserID returns the empty string; it exists only to implement ipnauth.Identity.
|
||||
func (id *unixIdentity) UserID() ipn.WindowsUserID {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Username returns the user name associated with the identity.
|
||||
func (id *unixIdentity) Username() (string, error) {
|
||||
if id.forceForTest.username != nil {
|
||||
return *id.forceForTest.username, nil
|
||||
}
|
||||
switch id.goos {
|
||||
case "darwin", "linux":
|
||||
uid, ok := id.creds.UserID()
|
||||
if !ok {
|
||||
return "", errors.New("missing user ID")
|
||||
}
|
||||
u, err := osuser.LookupByUID(uid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lookup user: %w", err)
|
||||
}
|
||||
return u.Username, nil
|
||||
default:
|
||||
return "", errors.New("unsupported OS")
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAccess reports whether user is allowed or denied the requested access.
|
||||
func (id *unixIdentity) CheckAccess(requested DeviceAccess) AccessCheckResult {
|
||||
if id.isPrivileged(nil, logger.Discard) {
|
||||
return AllowAccess()
|
||||
}
|
||||
allowed := GenerateBugReport | ReadDeviceStatus | InstallUpdates
|
||||
if requested&^allowed == 0 {
|
||||
return AllowAccess()
|
||||
}
|
||||
return DenyAccess(errMustBeRootOrOperator)
|
||||
}
|
||||
|
||||
// CheckProfileAccess reports whether user is allowed or denied the requested access to the profile.
|
||||
func (id *unixIdentity) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
|
||||
operatorUID := operatorUIDFromPrefs(prefs)
|
||||
checker := newAccessChecker(requested)
|
||||
// Deny access immediately if ServePath was requested, unless the user is root,
|
||||
// or both a sudoer and an operator.
|
||||
if checker.remaining()&ServePath != 0 {
|
||||
if !id.canServePath(operatorUID) {
|
||||
return checker.deny(ServePath, errMustBeRootOrSudoerOperator)
|
||||
}
|
||||
if res := checker.grant(ServePath); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
// Grant non-privileges access to everyone.
|
||||
if res := checker.grant(ReadProfileInfo | ListPeers | ReadPrefs | ReadServe); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
// Grant all access to root, admins and the operator.
|
||||
if id.isPrivileged(operatorUID, logger.Discard) {
|
||||
if res := checker.grant(UnrestrictedProfileAccess); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
// Grant cert fetching access to the TS_PERMIT_CERT_UID user.
|
||||
if id.canFetchCerts() {
|
||||
if res := checker.grant(FetchCerts); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
// Deny any other access.
|
||||
return DenyAccess(errMustBeRootOrOperator)
|
||||
}
|
||||
|
||||
func operatorUIDFromPrefs(prefs ipn.PrefsGetter) func() string {
|
||||
return func() string {
|
||||
prefs, err := prefs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
opUserName := prefs.OperatorUser()
|
||||
if opUserName == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := user.Lookup(opUserName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Uid
|
||||
}
|
||||
}
|
||||
|
||||
// isPrivileged reports whether the identity should be considered privileged,
|
||||
// meaning it's allowed to change the state of the node and access sensitive information.
|
||||
func (id *unixIdentity) isPrivileged(operatorUID func() string, logf logger.Logf) bool {
|
||||
if logf == nil {
|
||||
logf = func(format string, args ...any) {
|
||||
fmt.Printf("%s", fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
const ro, rw = false, true
|
||||
if !safesocket.GOOSUsesPeerCreds(id.goos) {
|
||||
return rw
|
||||
}
|
||||
if id.forceForTest.isAdmin != nil {
|
||||
return *id.forceForTest.isAdmin
|
||||
}
|
||||
creds := id.creds
|
||||
if creds == nil {
|
||||
logf("connection from unknown peer; read-only")
|
||||
return ro
|
||||
}
|
||||
uid, ok := creds.UserID()
|
||||
if !ok {
|
||||
logf("connection from peer with unknown userid; read-only")
|
||||
return ro
|
||||
}
|
||||
if uid == "0" {
|
||||
logf("connection from userid %v; root has access", uid)
|
||||
return rw
|
||||
}
|
||||
if selfUID := os.Getuid(); selfUID != 0 && uid == strconv.Itoa(selfUID) {
|
||||
logf("connection from userid %v; connection from non-root user matching daemon has access", uid)
|
||||
return rw
|
||||
}
|
||||
if operatorUID != nil {
|
||||
if operatorUID := operatorUID(); operatorUID != "" && uid == operatorUID {
|
||||
logf("connection from userid %v; is configured operator", uid)
|
||||
return rw
|
||||
}
|
||||
}
|
||||
if yes, err := isLocalAdmin(uid); err != nil {
|
||||
logf("connection from userid %v; read-only; %v", uid, err)
|
||||
return ro
|
||||
} else if yes {
|
||||
logf("connection from userid %v; is local admin, has access", uid)
|
||||
return rw
|
||||
}
|
||||
logf("connection from userid %v; read-only", uid)
|
||||
return ro
|
||||
}
|
||||
|
||||
// canFetchCerts reports whether id is allowed to fetch HTTPS
|
||||
// certs from this server when it wouldn't otherwise be able to.
|
||||
//
|
||||
// That is, this reports whether id should grant additional
|
||||
// capabilities over what the conn would otherwise be able to do.
|
||||
//
|
||||
// For now this only returns true on Unix machines when
|
||||
// TS_PERMIT_CERT_UID is set the to the userid of the peer
|
||||
// connection. It's intended to give your non-root webserver access
|
||||
// (www-data, caddy, nginx, etc) to certs.
|
||||
func (id *unixIdentity) canFetchCerts() bool {
|
||||
var uid string
|
||||
var hasUID bool
|
||||
if id.forceForTest.uid != nil {
|
||||
uid, hasUID = *id.forceForTest.uid, true
|
||||
} else if id.creds != nil {
|
||||
uid, hasUID = id.creds.UserID()
|
||||
}
|
||||
if hasUID && uid == userIDFromString(envknob.String("TS_PERMIT_CERT_UID")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (id *unixIdentity) canServePath(operatorUID func() string) bool {
|
||||
switch id.goos {
|
||||
case "linux", "darwin":
|
||||
// continue
|
||||
case "windows":
|
||||
panic("unreachable")
|
||||
default:
|
||||
return id.isPrivileged(operatorUID, logger.Discard)
|
||||
}
|
||||
// Only check for local admin on tailscaled-on-mac (based on "sudo"
|
||||
// permissions). On sandboxed variants (MacSys and AppStore), tailscaled
|
||||
// cannot serve files outside of the sandbox and this check is not
|
||||
// relevant.
|
||||
if id.goos == "darwin" && version.IsSandboxedMacOS() {
|
||||
return true
|
||||
}
|
||||
|
||||
return id.isLocalAdminForServe(operatorUID)
|
||||
}
|
||||
|
||||
// isLocalAdminForServe reports whether the identity representing a connected client
|
||||
// has administrative access to the local machine, for whatever that means with respect to the
|
||||
// current OS.
|
||||
//
|
||||
// This is useful because tailscaled itself always runs with elevated rights:
|
||||
// we want to avoid privilege escalation for certain mutative operations.
|
||||
func (id *unixIdentity) isLocalAdminForServe(operatorUID func() string) bool {
|
||||
if id.forceForTest.isAdmin != nil {
|
||||
return *id.forceForTest.isAdmin
|
||||
}
|
||||
switch id.goos {
|
||||
case "darwin":
|
||||
// Unknown, or at least unchecked on sandboxed macOS variants. Err on
|
||||
// the side of less permissions.
|
||||
//
|
||||
// canSetServePath should not call connIsLocalAdmin on sandboxed variants anyway.
|
||||
if version.IsSandboxedMacOS() {
|
||||
return false
|
||||
}
|
||||
// This is a standalone tailscaled setup, use the same logic as on
|
||||
// Linux.
|
||||
fallthrough
|
||||
case "linux":
|
||||
uid, ok := id.creds.UserID()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// root is always admin.
|
||||
if uid == "0" {
|
||||
return true
|
||||
}
|
||||
// if non-root, must be operator AND able to execute "sudo tailscale".
|
||||
if operatorUID := operatorUID(); operatorUID != "" && uid != operatorUID {
|
||||
return false
|
||||
}
|
||||
u, err := osuser.LookupByUID(uid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Short timeout just in case sudo hangs for some reason.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isLocalAdmin(uid string) (bool, error) {
|
||||
u, err := user.LookupId(uid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var adminGroup string
|
||||
switch {
|
||||
case runtime.GOOS == "darwin":
|
||||
adminGroup = "admin"
|
||||
case distro.Get() == distro.QNAP:
|
||||
adminGroup = "administrators"
|
||||
default:
|
||||
return false, errors.New("no system admin group found")
|
||||
}
|
||||
return groupmember.IsMemberOfGroup(adminGroup, u.Username)
|
||||
}
|
||||
|
||||
// userIDFromString maps from either a numeric user id in string form
|
||||
// ("998") or username ("caddy") to its string userid ("998").
|
||||
// It returns the empty string on error.
|
||||
func userIDFromString(v string) string {
|
||||
if v == "" || isAllDigit(v) {
|
||||
return v
|
||||
}
|
||||
u, err := user.Lookup(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Uid
|
||||
}
|
||||
|
||||
func isAllDigit(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if b := s[i]; b < '0' || b > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
221
ipn/ipnauth/win.go
Normal file
221
ipn/ipnauth/win.go
Normal file
@ -0,0 +1,221 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
var _ Identity = (*windowsIdentity)(nil)
|
||||
|
||||
// windowsIdentity represents identity of a Windows user.
|
||||
type windowsIdentity struct {
|
||||
tok WindowsToken
|
||||
env WindowsEnvironment
|
||||
}
|
||||
|
||||
// newWindowsIdentity returns a new WindowsIdentity with the specified token and environment.
|
||||
func newWindowsIdentity(tok WindowsToken, env WindowsEnvironment) *windowsIdentity {
|
||||
identity := &windowsIdentity{tok, env}
|
||||
runtime.SetFinalizer(identity, func(i *windowsIdentity) { i.Close() })
|
||||
return identity
|
||||
}
|
||||
|
||||
// UserID returns SID of a Windows user account.
|
||||
func (wi *windowsIdentity) UserID() ipn.WindowsUserID {
|
||||
if uid, err := wi.tok.UID(); err == nil {
|
||||
return uid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserID returns SID of a Windows user account.
|
||||
func (wi *windowsIdentity) Username() (string, error) {
|
||||
return wi.tok.Username()
|
||||
}
|
||||
|
||||
// CheckAccess reports whether wi is allowed or denied the requested access.
|
||||
func (wi *windowsIdentity) CheckAccess(requested DeviceAccess) AccessCheckResult {
|
||||
checker := newAccessChecker(requested)
|
||||
|
||||
// Debug and ResetAllProfiles access rights can only be granted to elevated admins.
|
||||
if res := checker.mustGrant(DeleteAllProfiles|Debug, wi.checkElevatedAdmin); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
|
||||
if wi.env.IsServer {
|
||||
// Only admins can create new profiles or install client updates on Windows Server devices.
|
||||
// However, we should allow these operations from non-elevated contexts (e.g GUI).
|
||||
if res := checker.tryGrant(CreateProfile|InstallUpdates, wi.checkAdmin); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
} else {
|
||||
// But any user should be able to create a profile or initiate an update on non-server (e.g. Windows 10/11) devices.
|
||||
if res := checker.grant(CreateProfile | InstallUpdates); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditionally grant ReadStatus and GenerateBugReport to all authenticated users, regardless of the environment.
|
||||
if res := checker.grant(ReadDeviceStatus | GenerateBugReport); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
|
||||
// Grant unrestricted device access to elevated admins.
|
||||
if res := checker.tryGrant(UnrestrictedDeviceAccess, wi.checkElevatedAdmin); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
|
||||
// Returns the final access check result, implicitly denying any access rights that have not been explicitly granted.
|
||||
return checker.result()
|
||||
}
|
||||
|
||||
// CheckProfileAccess reports whether wi is allowed or denied the requested access to the profile.
|
||||
func (wi *windowsIdentity) CheckProfileAccess(profile ipn.LoginProfileView, prefs ipn.PrefsGetter, requested ProfileAccess) AccessCheckResult {
|
||||
checker := newAccessChecker(requested)
|
||||
|
||||
// To avoid privilege escalation, the ServePath access right must only be granted to elevated admins.
|
||||
// The access request will be immediately denied if wi is not an elevated admin.
|
||||
if res := checker.mustGrant(ServePath, wi.checkElevatedAdmin); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
|
||||
// Profile owners have unrestricted access to their own profiles.
|
||||
if wi.isProfileOwner(profile) {
|
||||
if res := checker.grant(UnrestrictedProfileAccess); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
if isProfileShared(profile, prefs) {
|
||||
// Allow all users to read basic profile info (e.g. profile and tailnet name)
|
||||
// and list network device for shared profiles.
|
||||
// Profile is considered shared if it has unattended mode enabled
|
||||
// and/or is not owned by a specific user (e.g. created via MDM/GP).
|
||||
sharedProfileRights := ReadProfileInfo | ListPeers
|
||||
if !wi.env.IsServer && !wi.env.IsManaged {
|
||||
// Additionally, on non-managed Windows client devices we should allow users to
|
||||
// connect / disconnect, read preferences and select exit nodes.
|
||||
sharedProfileRights |= Connect | Disconnect | ReadPrefs | ChangeExitNode
|
||||
}
|
||||
if res := checker.grant(sharedProfileRights); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
if !wi.env.IsServer && !isProfileEnforced(profile, prefs) {
|
||||
// Allow any user to disconnect from non-enforced Tailnets on non-Windows Server devices.
|
||||
// TODO(nickkhyl): automatically disconnect from the current Tailnet
|
||||
// when a different user logs in or unlocks their Windows session,
|
||||
// unless the unattended mode is enabled. But in the meantime, we should allow users
|
||||
// to disconnect themselves.
|
||||
if res := checker.grant(Disconnect); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
if isAdmin, _ := wi.tok.IsAdministrator(); isAdmin {
|
||||
// Allow local admins to disconnect from any tailnet.
|
||||
localAdminRights := Disconnect
|
||||
if wi.tok.IsElevated() {
|
||||
// Allow elevated admins unrestricted access to all local profiles,
|
||||
// except for reading private keys.
|
||||
localAdminRights |= UnrestrictedProfileAccess & ^ReadPrivateKeys
|
||||
}
|
||||
if isProfileShared(profile, prefs) {
|
||||
// Allow all admins unrestricted access to shared profiles,
|
||||
// except for reading private keys.
|
||||
// This is to allow shared profiles created by others (admins or users)
|
||||
// to be managed from the GUI client.
|
||||
localAdminRights |= UnrestrictedProfileAccess & ^ReadPrivateKeys
|
||||
}
|
||||
if res := checker.grant(localAdminRights); res.HasResult() {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
return checker.result()
|
||||
}
|
||||
|
||||
// Close implements io.Closer by releasing resources associated with the Windows user identity.
|
||||
func (wi *windowsIdentity) Close() error {
|
||||
if wi == nil || wi.tok == nil {
|
||||
return nil
|
||||
}
|
||||
if err := wi.tok.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.SetFinalizer(wi, nil)
|
||||
wi.tok = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wi *windowsIdentity) checkAdmin() error {
|
||||
isAdmin, err := wi.tok.IsAdministrator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isAdmin {
|
||||
return errors.New("the requested operation requires local admin rights")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wi *windowsIdentity) checkElevatedAdmin() error {
|
||||
if !wi.tok.IsElevated() {
|
||||
return errors.New("the requested operation requires elevation")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wi *windowsIdentity) isProfileOwner(profile ipn.LoginProfileView) bool {
|
||||
return wi.tok.IsUID(profile.LocalUserID())
|
||||
}
|
||||
|
||||
// isProfileShared reports whether the specified profile is considered shared,
|
||||
// meaning that all local users should have at least ReadProfileInfo and ListPeers
|
||||
// access to it, but may be granted additional access rights based on the environment
|
||||
// and their role on the device.
|
||||
func isProfileShared(profile ipn.LoginProfileView, prefs ipn.PrefsGetter) bool {
|
||||
if profile.LocalUserID() == "" {
|
||||
// Profiles created as LocalSystem (e.g. via MDM) can be used by everyone on the device.
|
||||
return true
|
||||
}
|
||||
if prefs, err := prefs(); err == nil {
|
||||
// Profiles that have unattended mode enabled can be used by everyone on the device.
|
||||
return prefs.ForceDaemon()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isProfileEnforced(ipn.LoginProfileView, ipn.PrefsGetter) bool {
|
||||
// TODO(nickkhyl): allow to mark profiles as enforced to prevent
|
||||
// regular users from disconnecting.
|
||||
return false
|
||||
}
|
||||
|
||||
// WindowsEnvironment describes the current Windows environment.
|
||||
type WindowsEnvironment struct {
|
||||
IsServer bool // whether running on a server edition of Windows
|
||||
IsManaged bool // whether the device is managed (domain-joined or MDM-enrolled)
|
||||
}
|
||||
|
||||
// String returns a string representation of the environment.
|
||||
func (env WindowsEnvironment) String() string {
|
||||
switch {
|
||||
case env.IsManaged && env.IsServer:
|
||||
return "Managed Server"
|
||||
case env.IsManaged && !env.IsServer:
|
||||
return "Managed Client"
|
||||
case !env.IsManaged && env.IsServer:
|
||||
return "Non-Managed Server"
|
||||
case !env.IsManaged && !env.IsServer:
|
||||
return "Non-Managed Client"
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
258
ipn/ipnauth/win_test.go
Normal file
258
ipn/ipnauth/win_test.go
Normal file
@ -0,0 +1,258 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package ipnauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
var (
|
||||
winServerEnvs = []WindowsEnvironment{
|
||||
{IsServer: true, IsManaged: false},
|
||||
{IsServer: true, IsManaged: true},
|
||||
}
|
||||
|
||||
winClientEnvs = []WindowsEnvironment{
|
||||
{IsServer: false, IsManaged: false},
|
||||
{IsServer: false, IsManaged: true},
|
||||
}
|
||||
|
||||
winManagedEnvs = []WindowsEnvironment{
|
||||
{IsServer: false, IsManaged: true},
|
||||
{IsServer: true, IsManaged: true},
|
||||
}
|
||||
|
||||
winAllEnvs = []WindowsEnvironment{
|
||||
{IsServer: false, IsManaged: false},
|
||||
{IsServer: false, IsManaged: true},
|
||||
{IsServer: true, IsManaged: false},
|
||||
{IsServer: true, IsManaged: true},
|
||||
}
|
||||
)
|
||||
|
||||
func TestDeviceAccessWindows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestAccess []DeviceAccess
|
||||
envs []WindowsEnvironment
|
||||
tok WindowsToken
|
||||
wantAllow bool
|
||||
}{
|
||||
{
|
||||
name: "allow-all-access-elevated-admin",
|
||||
requestAccess: []DeviceAccess{UnrestrictedDeviceAccess},
|
||||
envs: winAllEnvs,
|
||||
tok: &testToken{Admin: true, Elevated: true},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-create-profile-non-elevated-admin",
|
||||
requestAccess: []DeviceAccess{CreateProfile},
|
||||
envs: winAllEnvs,
|
||||
tok: &testToken{Admin: true, Elevated: false},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-install-updates-non-elevated-admin",
|
||||
requestAccess: []DeviceAccess{InstallUpdates},
|
||||
envs: winAllEnvs,
|
||||
tok: &testToken{Admin: true, Elevated: false},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-privileged-access-non-elevated-admin",
|
||||
requestAccess: []DeviceAccess{Debug, DeleteAllProfiles},
|
||||
envs: winAllEnvs,
|
||||
tok: &testToken{Admin: true, Elevated: false},
|
||||
wantAllow: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "allow-read-access-user",
|
||||
requestAccess: []DeviceAccess{ReadDeviceStatus, GenerateBugReport},
|
||||
envs: winAllEnvs,
|
||||
tok: &testToken{Admin: false},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-privileged-access-user",
|
||||
requestAccess: []DeviceAccess{Debug, DeleteAllProfiles},
|
||||
envs: winAllEnvs,
|
||||
tok: &testToken{Admin: false},
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-create-profile-non-server-user",
|
||||
requestAccess: []DeviceAccess{CreateProfile},
|
||||
envs: winClientEnvs,
|
||||
tok: &testToken{Admin: false},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-create-profile-server-user",
|
||||
requestAccess: []DeviceAccess{CreateProfile},
|
||||
envs: winServerEnvs,
|
||||
tok: &testToken{Admin: false},
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-install-updates-non-server-user",
|
||||
requestAccess: []DeviceAccess{InstallUpdates},
|
||||
envs: winClientEnvs,
|
||||
tok: &testToken{Admin: false},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-install-updates-server-user",
|
||||
requestAccess: []DeviceAccess{InstallUpdates},
|
||||
envs: winServerEnvs,
|
||||
tok: &testToken{Admin: false},
|
||||
wantAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, env := range tt.envs {
|
||||
user := newWindowsIdentity(tt.tok, env)
|
||||
for _, access := range tt.requestAccess {
|
||||
testName := tt.name + "-" + env.String() + "-" + access.String()
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
if res := user.CheckAccess(access); res.Allowed() != tt.wantAllow {
|
||||
t.Errorf("got result: %v, want allow: %v", res, tt.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccessWindows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tok WindowsToken
|
||||
profile ipn.LoginProfile
|
||||
prefs ipn.Prefs
|
||||
envs []WindowsEnvironment
|
||||
requestAccess []ProfileAccess
|
||||
wantAllow bool
|
||||
}{
|
||||
{
|
||||
name: "allow-users-access-to-own-profiles",
|
||||
tok: &testToken{Admin: false, SID: "User1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User1"},
|
||||
envs: winAllEnvs,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^(ServePath)}, // ServePath requires elevated admin rights
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-users-disconnect-access-to-others-profiles-on-clients",
|
||||
tok: &testToken{Admin: false, SID: "User1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User2"},
|
||||
envs: winClientEnvs,
|
||||
requestAccess: []ProfileAccess{Disconnect},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-users-access-to-others-unattended-profiles-on-unmanaged-clients",
|
||||
tok: &testToken{Admin: false, SID: "User1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User2"},
|
||||
prefs: ipn.Prefs{ForceDaemon: true},
|
||||
envs: []WindowsEnvironment{{IsServer: false, IsManaged: false}},
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, Connect, Disconnect, ListPeers, ReadPrefs, ChangeExitNode},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-users-read-access-to-others-unattended-profiles-on-managed",
|
||||
tok: &testToken{Admin: false, SID: "User1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User2"},
|
||||
prefs: ipn.Prefs{ForceDaemon: true},
|
||||
envs: winManagedEnvs,
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, ListPeers},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-users-read-access-to-others-unattended-profiles-on-servers",
|
||||
tok: &testToken{Admin: false, SID: "User1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User2"},
|
||||
prefs: ipn.Prefs{ForceDaemon: true},
|
||||
envs: winServerEnvs,
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, ListPeers},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-users-access-to-non-unattended-others-profiles",
|
||||
tok: &testToken{Admin: false, SID: "User1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User2"},
|
||||
envs: winAllEnvs,
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, Connect, ListPeers, ReadPrefs, ChangePrefs, ChangeExitNode},
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-elevated-admins-access-to-others-profiles",
|
||||
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User1"},
|
||||
envs: winAllEnvs,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^ReadPrivateKeys}, // ReadPrivateKeys is never allowed to others' profiles.
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-non-elevated-admins-access-to-shared-profiles",
|
||||
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: ""},
|
||||
envs: winManagedEnvs,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^(ReadPrivateKeys | ServePath)},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "allow-non-elevated-admins-access-to-unattended-profiles",
|
||||
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: ""},
|
||||
prefs: ipn.Prefs{ForceDaemon: true},
|
||||
envs: winManagedEnvs,
|
||||
requestAccess: []ProfileAccess{UnrestrictedProfileAccess & ^(ReadPrivateKeys | ServePath)},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-non-elevated-admins-access-to-others-profiles",
|
||||
tok: &testToken{Admin: true, Elevated: false, SID: "Admin1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "User1"},
|
||||
envs: winAllEnvs,
|
||||
requestAccess: []ProfileAccess{ReadProfileInfo, Connect, ListPeers, ReadPrefs, ChangePrefs, ChangeExitNode},
|
||||
wantAllow: false,
|
||||
},
|
||||
{
|
||||
name: "allow-elevated-admins-serve-path-for-own-profiles",
|
||||
tok: &testToken{Admin: true, Elevated: true, SID: "Admin1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "Admin1"},
|
||||
envs: winAllEnvs,
|
||||
requestAccess: []ProfileAccess{ServePath},
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "deny-non-elevated-admins-serve-path-for-own-profiles",
|
||||
tok: &testToken{Admin: true, Elevated: false, SID: "Admin1"},
|
||||
profile: ipn.LoginProfile{LocalUserID: "Admin1"},
|
||||
envs: winAllEnvs,
|
||||
requestAccess: []ProfileAccess{ServePath},
|
||||
wantAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, env := range tt.envs {
|
||||
user := newWindowsIdentity(tt.tok, env)
|
||||
for _, access := range tt.requestAccess {
|
||||
testName := tt.name + "-" + env.String() + "-" + access.String()
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
res := user.CheckProfileAccess(tt.profile.View(), ipn.PrefsGetterFor(tt.prefs.View()), access)
|
||||
if res.Allowed() != tt.wantAllow {
|
||||
t.Errorf("got result: %v, want allow: %v", res, tt.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
13
ipn/prefs.go
13
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
|
||||
|
Loading…
Reference in New Issue
Block a user