You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/ipn/ipnauth/access_check.go

356 lines
13 KiB
Go

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>
2 months ago
// 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
}