mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-23 01:11:40 +00:00

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>
551 lines
14 KiB
Go
551 lines
14 KiB
Go
// 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)
|
|
}
|
|
}
|