mirror of
https://github.com/gravitational/teleport.git
synced 2026-08-29 05:27:37 +08:00
4f17314a11
* Convert existing non-gogo codegen to the Hybrid API Contributes to https://github.com/gravitational/teleport/issues/66776. All existing protos explicitly set to API_OPEN have been change to API_HBYRID. The new codegen was performed via make grpc. There are no other functional changes to the code to start consuming the Hybrid API those will come later. The intent is to get all Hybrid codegen in and backported to ease the transition. * Initial migration to the Opaque API Contributes to https://github.com/gravitational/teleport/issues/66776. All of the changes here are mechanical conversions generated from `open2opaque rewrite -levels=green ./...`. There will be a follow up to this in teleport.e which does the same. Once all changes have been merged the process will be repeated with -levels=yellow followed by -levels=red. See https://protobuf.dev/reference/go/opaque-migration/ for more details.
1549 lines
52 KiB
Go
1549 lines
52 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sync/errgroup"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
"github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/utils"
|
|
api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1"
|
|
dbhelpers "github.com/gravitational/teleport/integration/db"
|
|
"github.com/gravitational/teleport/integration/helpers"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/authtest"
|
|
"github.com/gravitational/teleport/lib/auth/mocku2f"
|
|
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
|
|
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
|
|
"github.com/gravitational/teleport/lib/client"
|
|
"github.com/gravitational/teleport/lib/modules/modulestest"
|
|
"github.com/gravitational/teleport/lib/service"
|
|
"github.com/gravitational/teleport/lib/service/servicecfg"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/teleterm/api/uri"
|
|
"github.com/gravitational/teleport/lib/teleterm/apiserver/handler"
|
|
"github.com/gravitational/teleport/lib/teleterm/clusteridcache"
|
|
"github.com/gravitational/teleport/lib/teleterm/clusters"
|
|
"github.com/gravitational/teleport/lib/teleterm/daemon"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
"github.com/gravitational/teleport/lib/utils/log/logtest"
|
|
)
|
|
|
|
func TestTeleterm(t *testing.T) {
|
|
pack := dbhelpers.SetupDatabaseTest(t,
|
|
dbhelpers.WithListenerSetupDatabaseTest(helpers.SingleProxyPortSetup),
|
|
dbhelpers.WithLeafConfig(func(config *servicecfg.Config) {
|
|
config.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
|
|
config.InsecureMode = true
|
|
config.Modules = modulestest.EnterpriseModules()
|
|
}),
|
|
dbhelpers.WithRootConfig(func(config *servicecfg.Config) {
|
|
config.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
|
|
config.InsecureMode = true
|
|
config.Modules = modulestest.EnterpriseModules()
|
|
}),
|
|
)
|
|
pack.WaitForLeaf(t)
|
|
|
|
creds, err := helpers.GenerateUserCreds(helpers.UserCredsRequest{
|
|
Process: pack.Root.Cluster.Process,
|
|
Username: pack.Root.User.GetName(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("adding root cluster", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testAddingRootCluster(t, pack, creds)
|
|
})
|
|
|
|
t.Run("ListRootClusters returns logged in user", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testListRootClustersReturnsLoggedInUser(t, pack, creds)
|
|
})
|
|
|
|
t.Run("GetCluster returns properties from auth server", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testGetClusterReturnsPropertiesFromAuthServer(t, pack)
|
|
})
|
|
|
|
t.Run("headless watcher", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testHeadlessWatcher(t, pack, creds)
|
|
})
|
|
|
|
t.Run("CreateConnectMyComputerRole", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCreateConnectMyComputerRole(t, pack)
|
|
})
|
|
|
|
t.Run("CreateConnectMyComputerToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
testCreateConnectMyComputerToken(t, pack, nil /* setupUserMFA */)
|
|
})
|
|
|
|
t.Run("WaitForConnectMyComputerNodeJoin", func(t *testing.T) {
|
|
t.Parallel()
|
|
testWaitForConnectMyComputerNodeJoin(t, pack, creds)
|
|
})
|
|
|
|
t.Run("DeleteConnectMyComputerNode", func(t *testing.T) {
|
|
t.Parallel()
|
|
testDeleteConnectMyComputerNode(t, pack)
|
|
})
|
|
|
|
t.Run("client cache", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testClientCache(t, pack, creds)
|
|
})
|
|
|
|
t.Run("clearing stale cached clients", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testClearingStaleCachedClients(t, pack, creds)
|
|
})
|
|
|
|
t.Run("logging out", func(t *testing.T) {
|
|
t.Parallel()
|
|
testLogout(t, pack, creds)
|
|
})
|
|
|
|
t.Run("setting site name", func(t *testing.T) {
|
|
t.Parallel()
|
|
testSettingSiteName(t, pack, creds)
|
|
})
|
|
|
|
t.Run("ListUnifiedResources returns database users", func(t *testing.T) {
|
|
// ListDatabaseUsers cannot be run in parallel as it modifies the default roles of users set up
|
|
// through the test pack.
|
|
// TODO(ravicious): After some optimizations, those tests could run in parallel. Instead of
|
|
// modifying existing roles, they could create new users with new roles and then update the role
|
|
// mapping between the root the leaf cluster through authServer.UpdateUserCARoleMap.
|
|
testListDatabaseUsersFromUnifiedResources(t, pack)
|
|
})
|
|
|
|
t.Run("with MFA", func(t *testing.T) {
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
rpID, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
|
|
// Enforce MFA
|
|
helpers.UpsertAuthPrefAndWaitForCache(t, context.Background(), authServer, &types.AuthPreferenceV2{
|
|
Spec: types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: rpID,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Remove MFA enforcement on cleanup.
|
|
t.Cleanup(func() {
|
|
helpers.UpsertAuthPrefAndWaitForCache(t, context.Background(), authServer, &types.AuthPreferenceV2{
|
|
Spec: types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
setupUserMFA := func(t *testing.T, userName string, tshdEventsService *mockTSHDEventsService) client.WebauthnLoginFunc {
|
|
// Configure user account with an MFA device.
|
|
origin := fmt.Sprintf("https://%s", rpID)
|
|
device, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
device.SetPasswordless()
|
|
|
|
token, err := authServer.CreateResetPasswordToken(context.Background(), authclient.CreateUserTokenRequest{
|
|
Name: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tokenID := token.GetName()
|
|
res, err := authServer.CreateRegisterChallenge(context.Background(), &proto.CreateRegisterChallengeRequest{
|
|
TokenID: tokenID,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
|
})
|
|
require.NoError(t, err)
|
|
cc := wantypes.CredentialCreationFromProto(res.GetWebauthn())
|
|
|
|
ccr, err := device.SignCredentialCreation(origin, cc)
|
|
require.NoError(t, err)
|
|
_, err = authServer.ChangeUserAuthentication(context.Background(), &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: tokenID,
|
|
NewMFARegisterResponse: &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wantypes.CredentialCreationResponseToProto(ccr),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Prepare a function which simulates key tap.
|
|
var webauthLoginCallCount atomic.Uint32
|
|
webauthnLogin := func(ctx context.Context, origin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, opts *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
|
|
t.Helper()
|
|
updatedWebauthnLoginCallCount := webauthLoginCallCount.Add(1)
|
|
|
|
// When daemon.mfaPrompt prompts for MFA, it spawns two goroutines. One calls PromptMFA on
|
|
// tshdEventService and expects OTP in response (if available). Another calls this function.
|
|
// Whichever returns a non-error response first wins.
|
|
//
|
|
// Since in this test we use Webauthn, this function can return ASAP without giving a chance
|
|
// to the other to call PromptMFA. This would cause race conditions, as we might want to
|
|
// verify later in the test that PromptMFA has indeed been called.
|
|
//
|
|
// To ensure that, this function waits until PromptMFA has been called before proceeding.
|
|
// This also simulates a flow where the user was notified about the need to tap the key
|
|
// through the UI and then taps the key.
|
|
assert.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
// Each call to webauthnLogin should have an equivalent call to PromptMFA and there should
|
|
// be no multiple concurrent calls.
|
|
assert.Equal(t, updatedWebauthnLoginCallCount, tshdEventsService.promptMFACallCount.Load(),
|
|
"Expected each call to webauthnLogin to have an equivalent call to PromptMFA")
|
|
}, 5*time.Second, 50*time.Millisecond)
|
|
|
|
car, err := device.SignAssertion(origin, assertion)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
carProto := wantypes.CredentialAssertionResponseToProto(car)
|
|
|
|
return &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: carProto,
|
|
},
|
|
}, "", nil
|
|
}
|
|
|
|
return webauthnLogin
|
|
}
|
|
|
|
t.Run("CreateConnectMyComputerToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCreateConnectMyComputerToken(t, pack, setupUserMFA)
|
|
})
|
|
})
|
|
}
|
|
|
|
func testAddingRootCluster(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
t.Helper()
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: client.NewFSClientStore(t.TempDir()),
|
|
InsecureSkipVerify: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
addedCluster, err := daemonService.AddCluster(context.Background(), pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
|
|
clusters, err := daemonService.ListRootClusters(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
clusterURIs := make([]uri.ResourceURI, 0, len(clusters))
|
|
for _, cluster := range clusters {
|
|
clusterURIs = append(clusterURIs, cluster.URI)
|
|
}
|
|
require.ElementsMatch(t, clusterURIs, []uri.ResourceURI{addedCluster.URI})
|
|
}
|
|
|
|
func testListRootClustersReturnsLoggedInUser(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
response, err := handler.ListRootClusters(context.Background(), &api.ListClustersRequest{})
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, response.GetClusters(), 1)
|
|
require.Equal(t, pack.Root.User.GetName(), response.GetClusters()[0].GetLoggedInUser().GetName())
|
|
}
|
|
|
|
func testGetClusterReturnsPropertiesFromAuthServer(t *testing.T, pack *dbhelpers.DatabasePack) {
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
|
|
// Use random names to not collide with other tests.
|
|
uuid := uuid.NewString()
|
|
suggestedReviewer := "suggested-reviewer"
|
|
requestableRoleName := fmt.Sprintf("%s-%s", "requested-role", uuid)
|
|
userName := fmt.Sprintf("%s-%s", "user", uuid)
|
|
roleName := fmt.Sprintf("%s-%s", "get-cluster-role", uuid)
|
|
|
|
requestableRole, err := types.NewRole(requestableRoleName, types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
|
|
// Create user role with ability to request role
|
|
userRole, err := types.NewRole(roleName, types.RoleSpecV6{
|
|
Options: types.RoleOptions{},
|
|
Allow: types.RoleConditions{
|
|
Logins: []string{
|
|
userName,
|
|
},
|
|
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{requestableRoleName},
|
|
SuggestedReviewers: []string{suggestedReviewer},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// add role that user can request
|
|
_, err = authServer.UpsertRole(context.Background(), requestableRole)
|
|
require.NoError(t, err)
|
|
|
|
// add role that allows to request "requestableRole"
|
|
_, err = authServer.UpsertRole(context.Background(), userRole)
|
|
require.NoError(t, err)
|
|
|
|
user, err := types.NewUser(userName)
|
|
user.AddRole(userRole.GetName())
|
|
require.NoError(t, err)
|
|
|
|
_, err = authServer.UpsertUser(context.Background(), user)
|
|
require.NoError(t, err)
|
|
|
|
creds, err := helpers.GenerateUserCreds(helpers.UserCredsRequest{
|
|
Process: pack.Root.Cluster.Process,
|
|
Username: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tc := mustLogin(t, userName, pack, creds)
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
clusterIDCache := clusteridcache.Cache{}
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
ClusterIDCache: &clusterIDCache,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
rootClusterName, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
clusterURI := uri.NewClusterURI(rootClusterName)
|
|
|
|
response, err := handler.GetCluster(context.Background(), api.GetClusterRequest_builder{
|
|
ClusterUri: clusterURI.String(),
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, userName, response.GetLoggedInUser().GetName())
|
|
require.ElementsMatch(t, []string{requestableRoleName}, response.GetLoggedInUser().GetRequestableRoles())
|
|
require.ElementsMatch(t, []string{suggestedReviewer}, response.GetLoggedInUser().GetSuggestedReviewers())
|
|
|
|
// Verify that cluster ID cache gets updated.
|
|
clusterIDFromCache, ok := clusterIDCache.Load(clusterURI)
|
|
require.True(t, ok, "ID for cluster %q was not found in the cache", clusterURI)
|
|
require.NotEmpty(t, clusterIDFromCache)
|
|
require.Equal(t, response.GetAuthClusterId(), clusterIDFromCache)
|
|
}
|
|
|
|
func testHeadlessWatcher(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cluster, _, err := storage.Add(ctx, tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
|
|
tshdEventsClient := daemon.NewTshdEventsClient(func() (grpc.DialOption, error) {
|
|
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
|
|
})
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
TshdEventsClient: tshdEventsClient,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
expires := pack.Root.Cluster.Config.Clock.Now().Add(time.Minute)
|
|
ha, err := types.NewHeadlessAuthentication(pack.Root.User.GetName(), "uuid", expires)
|
|
require.NoError(t, err)
|
|
ha.State = types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_PENDING
|
|
|
|
// Start the tshd event service and connect the daemon to it.
|
|
tshdEventsService, addr := newMockTSHDEventsServiceServer(t)
|
|
err = daemonService.UpdateAndDialTshdEventsServerAddress(addr)
|
|
require.NoError(t, err)
|
|
|
|
err = daemonService.StartHeadlessWatcher(cluster.URI.String(), false /* waitInit */)
|
|
require.NoError(t, err)
|
|
|
|
// Stop and restart the watcher twice to simulate logout + login + relogin. Ensure the watcher catches events.
|
|
|
|
err = daemonService.StopHeadlessWatcher(cluster.URI.String())
|
|
require.NoError(t, err)
|
|
err = daemonService.StartHeadlessWatcher(cluster.URI.String(), false /* waitInit */)
|
|
require.NoError(t, err)
|
|
err = daemonService.StartHeadlessWatcher(cluster.URI.String(), true /* waitInit */)
|
|
require.NoError(t, err)
|
|
|
|
// Ensure the watcher catches events and sends them to the Electron App.
|
|
|
|
err = pack.Root.Cluster.Process.GetAuthServer().UpsertHeadlessAuthentication(ctx, ha)
|
|
assert.NoError(t, err)
|
|
|
|
assert.Eventually(t,
|
|
func() bool {
|
|
return tshdEventsService.sendPendingHeadlessAuthenticationCount.Load() == 1
|
|
},
|
|
10*time.Second,
|
|
500*time.Millisecond,
|
|
"Expected tshdEventService to receive 1 SendPendingHeadlessAuthentication message but got %v",
|
|
tshdEventsService.sendPendingHeadlessAuthenticationCount.Load(),
|
|
)
|
|
}
|
|
|
|
func testClientCache(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
ctx := context.Background()
|
|
|
|
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
|
|
|
|
storageFakeClock := clockwork.NewFakeClockAt(time.Now())
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
Clock: storageFakeClock,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cluster, _, err := storage.Add(ctx, tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
|
|
tshdEventsClient := daemon.NewTshdEventsClient(func() (grpc.DialOption, error) {
|
|
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
|
|
})
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
TshdEventsClient: tshdEventsClient,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
// Check if parallel calls trying to get a client will return the same one.
|
|
eg, egCtx := errgroup.WithContext(ctx)
|
|
blocker := make(chan struct{})
|
|
const concurrentCalls = 5
|
|
concurrentCallsForClient := make([]*client.ClusterClient, concurrentCalls)
|
|
for i := range concurrentCallsForClient {
|
|
client := &concurrentCallsForClient[i]
|
|
eg.Go(func() error {
|
|
<-blocker
|
|
c, err := daemonService.GetCachedClient(egCtx, cluster.URI)
|
|
*client = c
|
|
return err
|
|
})
|
|
}
|
|
// unblock the operation which is still in progress
|
|
close(blocker)
|
|
require.NoError(t, eg.Wait())
|
|
require.Subset(t, concurrentCallsForClient[:1], concurrentCallsForClient[1:])
|
|
|
|
// Since we have a client in the cache, it should be returned.
|
|
secondCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
|
|
require.NoError(t, err)
|
|
require.Equal(t, concurrentCallsForClient[0], secondCallForClient)
|
|
|
|
// Reissue user certs by assuming a role with a bogus ID in DropAccessRequests.
|
|
// This makes the cached client stale.
|
|
accessRequest := api.AssumeRoleRequest_builder{
|
|
RootClusterUri: cluster.URI.String(),
|
|
DropRequestIds: []string{"does-not-matter"},
|
|
}.Build()
|
|
err = cluster.AssumeRole(ctx, secondCallForClient, accessRequest)
|
|
require.NoError(t, err)
|
|
|
|
// Clearing stale clients should delete the stale client and force a new one.
|
|
err = daemonService.ClearStaleCachedClientsForRoot(cluster.URI)
|
|
require.NoError(t, err)
|
|
thirdCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, secondCallForClient, thirdCallForClient)
|
|
}
|
|
|
|
func testClearingStaleCachedClients(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
ctx := context.Background()
|
|
|
|
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
|
|
|
|
storageFakeClock := clockwork.NewFakeClockAt(time.Now())
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
Clock: storageFakeClock,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cluster, _, err := storage.Add(ctx, tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
|
|
tshdEventsClient := daemon.NewTshdEventsClient(func() (grpc.DialOption, error) {
|
|
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
|
|
})
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
TshdEventsClient: tshdEventsClient,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
firstCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
|
|
require.NoError(t, err)
|
|
err = daemonService.ClearStaleCachedClientsForRoot(cluster.URI)
|
|
require.NoError(t, err)
|
|
// Ensure the client wasn't closed.
|
|
secondCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
|
|
require.NoError(t, err)
|
|
require.Equal(t, firstCallForClient, secondCallForClient)
|
|
// Reissue user certs by assuming a role with a bogus ID in DropAccessRequests.
|
|
accessRequest := api.AssumeRoleRequest_builder{
|
|
RootClusterUri: cluster.URI.String(),
|
|
DropRequestIds: []string{"does-not-matter"},
|
|
}.Build()
|
|
err = cluster.AssumeRole(ctx, firstCallForClient, accessRequest)
|
|
require.NoError(t, err)
|
|
// The cert has changed, so after clearing stale clients,
|
|
// GetCachedClient should return a new client.
|
|
err = daemonService.ClearStaleCachedClientsForRoot(cluster.URI)
|
|
require.NoError(t, err)
|
|
thirdCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, secondCallForClient, thirdCallForClient)
|
|
}
|
|
|
|
func testLogout(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
ctx := context.Background()
|
|
|
|
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
|
|
|
|
storageFakeClock := clockwork.NewFakeClockAt(time.Now())
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
Clock: storageFakeClock,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cluster, _, err := storage.Add(ctx, tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
|
|
tshdEventsClient := daemon.NewTshdEventsClient(func() (grpc.DialOption, error) {
|
|
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
|
|
})
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
TshdEventsClient: tshdEventsClient,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
// Ensure there is a cluster.
|
|
rootClusters, err := daemonService.ListRootClusters(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, rootClusters, 1)
|
|
|
|
// Log out without removing the profile.
|
|
err = daemonService.ClusterLogout(ctx, cluster.URI, false)
|
|
require.NoError(t, err)
|
|
rootClusters, err = daemonService.ListRootClusters(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, rootClusters, 1)
|
|
require.Empty(t, rootClusters[0].GetLoggedInUser().Name)
|
|
|
|
// Log out again, now also remove the profile.
|
|
err = daemonService.ClusterLogout(ctx, cluster.URI, true)
|
|
require.NoError(t, err)
|
|
rootClusters, err = daemonService.ListRootClusters(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, rootClusters)
|
|
|
|
// Log out again, the operation should be idempotent.
|
|
err = daemonService.ClusterLogout(ctx, cluster.URI, true)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func testSettingSiteName(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
ctx := context.Background()
|
|
|
|
tc, err := pack.Root.Cluster.NewClient(helpers.ClientConfig{
|
|
TeleportUser: pack.Root.User.GetName(),
|
|
Cluster: "root.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
storageFakeClock := clockwork.NewFakeClockAt(time.Now())
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
Clock: storageFakeClock,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Add a cluster.
|
|
cluster, clusterClient, err := storage.Add(ctx, tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "root.example.com", clusterClient.SiteName)
|
|
require.Equal(t, "root.example.com", cluster.Name)
|
|
// Adding a cluster should set the site name in the profile.
|
|
profile, err := clusterClient.GetProfile(tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "root.example.com", profile.SiteName)
|
|
|
|
// Simulate logging into a leaf cluster, which changes the profile's site name.
|
|
clusterClient.SiteName = "leaf.example.com"
|
|
err = clusterClient.SaveProfile(false)
|
|
require.NoError(t, err)
|
|
|
|
// The URI should always resolve to the target cluster, even if the profile's site name points to a different cluster.
|
|
cluster, clusterClient, err = storage.ResolveCluster(cluster.URI)
|
|
require.NoError(t, err)
|
|
// These are empty because the user is not logged in, so there's no cert to retrieve the root cluster name.
|
|
require.Empty(t, clusterClient.SiteName)
|
|
require.Empty(t, cluster.Name)
|
|
// SiteName in the profile should still point to the leaf.
|
|
profile, err = clusterClient.GetProfile(tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "leaf.example.com", profile.SiteName)
|
|
|
|
// Saving the profile with SaveProfileAndPreserveSiteName doesn't overwrite the profile
|
|
// with the current clusterClient.SiteName.
|
|
err = clusters.SaveProfileAndPreserveSiteName(clusterClient, false)
|
|
require.NoError(t, err)
|
|
profile, err = clusterClient.GetProfile(tc.WebProxyAddr)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "leaf.example.com", profile.SiteName)
|
|
}
|
|
|
|
func testCreateConnectMyComputerRole(t *testing.T, pack *dbhelpers.DatabasePack) {
|
|
systemUser, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
assertCertsReloaded require.BoolAssertionFunc
|
|
existingRole func(userName string) types.RoleV6
|
|
assignExistingRoleToUser bool
|
|
}{
|
|
{
|
|
name: "role does not exist",
|
|
assertCertsReloaded: require.True,
|
|
},
|
|
{
|
|
name: "role exists and includes current system username",
|
|
assertCertsReloaded: require.True,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{userName},
|
|
},
|
|
Logins: []string{systemUser.Username},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "role exists and does not include current system username",
|
|
assertCertsReloaded: require.True,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{userName},
|
|
},
|
|
Logins: []string{fmt.Sprintf("bogus-login-%v", uuid.NewString())},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "role exists and has no logins",
|
|
assertCertsReloaded: require.True,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{userName},
|
|
},
|
|
Logins: []string{},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "role exists and owner node label was changed",
|
|
assertCertsReloaded: require.True,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{"bogus-username"},
|
|
},
|
|
Logins: []string{systemUser.Username},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "user already has existing role that includes current system username",
|
|
assignExistingRoleToUser: true,
|
|
assertCertsReloaded: require.False,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{userName},
|
|
},
|
|
Logins: []string{systemUser.Username},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "user already has existing role that does not include current system username",
|
|
assignExistingRoleToUser: true,
|
|
assertCertsReloaded: require.True,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{userName},
|
|
},
|
|
Logins: []string{fmt.Sprintf("bogus-login-%v", uuid.NewString())},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "user already has existing role with modified owner node label",
|
|
assignExistingRoleToUser: true,
|
|
assertCertsReloaded: require.False,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{"bogus-username"},
|
|
},
|
|
Logins: []string{systemUser.Username},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "user already has existing role that does not include current system username and has modified owner node label",
|
|
assignExistingRoleToUser: true,
|
|
assertCertsReloaded: require.True,
|
|
existingRole: func(userName string) types.RoleV6 {
|
|
return types.RoleV6{
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
NodeLabels: types.Labels{
|
|
types.ConnectMyComputerNodeOwnerLabel: []string{"bogus-username"},
|
|
},
|
|
Logins: []string{fmt.Sprintf("bogus-login-%v", uuid.NewString())},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
uuid := uuid.NewString()
|
|
userName := fmt.Sprintf("user-cmc-%s", uuid)
|
|
roleName := fmt.Sprintf("connect-my-computer-%v", userName)
|
|
|
|
var existingRole *types.RoleV6
|
|
|
|
// Prepare an existing role if present.
|
|
if test.existingRole != nil {
|
|
role := test.existingRole(userName)
|
|
role.SetMetadata(types.Metadata{
|
|
Name: roleName,
|
|
})
|
|
existingRole = &role
|
|
_, err := authServer.UpsertRole(ctx, &role)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Prepare a role with rules required to call CreateConnectMyComputerRole.
|
|
ruleWithAllowRules, err := types.NewRole(fmt.Sprintf("cmc-allow-rules-%v", uuid),
|
|
types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindUser, services.RW()),
|
|
types.NewRule(types.KindRole, services.RW()),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
userRoles := []types.Role{ruleWithAllowRules}
|
|
|
|
// Create a new user to avoid colliding with other tests.
|
|
// Assign to the user the role with allow rules and the existing role if present.
|
|
if test.assignExistingRoleToUser {
|
|
if existingRole == nil {
|
|
t.Log("assignExistingRoleToUser must be used together with existingRole")
|
|
t.Fail()
|
|
return
|
|
}
|
|
userRoles = append(userRoles, existingRole)
|
|
}
|
|
_, err = authtest.CreateUser(ctx, authServer, userName, userRoles...)
|
|
require.NoError(t, err)
|
|
|
|
userPassword := uuid
|
|
require.NoError(t, authServer.UpsertPassword(userName, []byte(userPassword)))
|
|
|
|
// Prepare daemon.Service.
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: client.NewFSClientStore(t.TempDir()),
|
|
InsecureSkipVerify: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
rootClusterName, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
rootClusterURI := uri.NewClusterURI(rootClusterName).String()
|
|
|
|
// Log in as the new user.
|
|
// It's important to use the actual login handler rather than mustLogin. mustLogin completely
|
|
// skips the actual login flow and saves valid certs to disk. We already had a regression that
|
|
// was not caught by this test because the test did not trigger certain code paths because it
|
|
// was using mustLogin as a shortcut.
|
|
_, err = handler.AddCluster(ctx, api.AddClusterRequest_builder{Name: pack.Root.Cluster.Web}.Build())
|
|
require.NoError(t, err)
|
|
_, err = handler.Login(ctx, api.LoginRequest_builder{
|
|
ClusterUri: rootClusterURI,
|
|
Local: api.LoginRequest_LocalParams_builder{User: userName, Password: userPassword}.Build(),
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
// Call CreateConnectMyComputerRole.
|
|
response, err := handler.CreateConnectMyComputerRole(ctx, api.CreateConnectMyComputerRoleRequest_builder{
|
|
RootClusterUri: rootClusterURI,
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
test.assertCertsReloaded(t, response.GetCertsReloaded(), "CertsReloaded is the opposite of the expected value")
|
|
|
|
// Verify that the role exists.
|
|
role, err := authServer.GetRole(ctx, roleName)
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the role grants expected privileges.
|
|
require.Contains(t, role.GetNodeLabels(types.Allow), types.ConnectMyComputerNodeOwnerLabel)
|
|
expectedNodeLabelValue := utils.Strings{userName}
|
|
actualNodeLabelValue := role.GetNodeLabels(types.Allow)[types.ConnectMyComputerNodeOwnerLabel]
|
|
require.Equal(t, expectedNodeLabelValue, actualNodeLabelValue)
|
|
require.Contains(t, role.GetLogins(types.Allow), systemUser.Username)
|
|
|
|
// Verify that the certs have been reloaded and that the user is assigned the role.
|
|
//
|
|
// GetCluster reads data from the cert. If the certs were not reloaded properly, GetCluster
|
|
// will not return the role that's just been assigned to the user.
|
|
clusterDetails, err := handler.GetCluster(ctx, api.GetClusterRequest_builder{
|
|
ClusterUri: rootClusterURI,
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
require.Contains(t, clusterDetails.GetLoggedInUser().GetRoles(), roleName,
|
|
"the user certs don't include the freshly added role; the certs might have not been reloaded properly")
|
|
})
|
|
}
|
|
}
|
|
|
|
func testCreateConnectMyComputerToken(t *testing.T, pack *dbhelpers.DatabasePack, setupUserMFA setupUserMFAFunc) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
uuid := uuid.NewString()
|
|
userName := fmt.Sprintf("user-cmc-%s", uuid)
|
|
|
|
// Prepare a role with rules required to call CreateConnectMyComputerNodeToken.
|
|
ruleWithAllowRules, err := types.NewRole(fmt.Sprintf("cmc-allow-rules-%v", uuid),
|
|
types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindToken, services.RW()),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
userRoles := []types.Role{ruleWithAllowRules}
|
|
|
|
_, err = authtest.CreateUser(ctx, authServer, userName, userRoles...)
|
|
require.NoError(t, err)
|
|
|
|
tshdEventsService, addr := newMockTSHDEventsServiceServer(t)
|
|
var webauthnLogin client.WebauthnLoginFunc
|
|
if setupUserMFA != nil {
|
|
webauthnLogin = setupUserMFA(t, userName, tshdEventsService)
|
|
}
|
|
|
|
// Log in as the new user.
|
|
creds, err := helpers.GenerateUserCreds(helpers.UserCredsRequest{
|
|
Process: pack.Root.Cluster.Process,
|
|
Username: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
tc := mustLogin(t, userName, pack, creds)
|
|
|
|
fakeClock := clockwork.NewFakeClock()
|
|
|
|
// Prepare daemon.Service.
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
Clock: fakeClock,
|
|
WebauthnLogin: webauthnLogin,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tshdEventsClient := daemon.NewTshdEventsClient(func() (grpc.DialOption, error) {
|
|
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
|
|
})
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Clock: fakeClock,
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
TshdEventsClient: tshdEventsClient,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = daemonService.UpdateAndDialTshdEventsServerAddress(addr)
|
|
require.NoError(t, err)
|
|
|
|
// Call CreateConnectMyComputerNodeToken.
|
|
rootClusterName, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
rootClusterURI := uri.NewClusterURI(rootClusterName).String()
|
|
requestCreatedAt := fakeClock.Now()
|
|
createdTokenResponse, err := handler.CreateConnectMyComputerNodeToken(ctx, api.CreateConnectMyComputerNodeTokenRequest_builder{
|
|
RootClusterUri: rootClusterURI,
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
// Verify that token exists
|
|
tokenFromAuthServer, err := authServer.GetToken(ctx, createdTokenResponse.GetToken())
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the token can be used to join nodes...
|
|
require.Equal(t, types.SystemRoles{types.RoleNode}, tokenFromAuthServer.GetRoles())
|
|
// ...and is valid for no longer than 5 minutes.
|
|
require.LessOrEqual(t, tokenFromAuthServer.Expiry(), requestCreatedAt.Add(5*time.Minute))
|
|
|
|
if setupUserMFA != nil {
|
|
require.Equal(t, uint32(1), tshdEventsService.promptMFACallCount.Load(),
|
|
"Unexpected number of calls to TSHDEventsClient.PromptMFA")
|
|
}
|
|
}
|
|
|
|
func testWaitForConnectMyComputerNodeJoin(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agentsDir := t.TempDir()
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: agentsDir,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
profileName, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
|
|
waitForNodeJoinErr := make(chan error)
|
|
|
|
go func() {
|
|
_, err := handler.WaitForConnectMyComputerNodeJoin(ctx, api.WaitForConnectMyComputerNodeJoinRequest_builder{
|
|
RootClusterUri: uri.NewClusterURI(profileName).String(),
|
|
}.Build())
|
|
waitForNodeJoinErr <- err
|
|
}()
|
|
|
|
// Start the new node.
|
|
nodeConfig := newNodeConfig(t, "token", types.JoinMethodToken)
|
|
nodeConfig.SetAuthServerAddress(pack.Root.Cluster.Config.Auth.ListenAddr)
|
|
nodeConfig.DataDir = filepath.Join(agentsDir, profileName, "data")
|
|
nodeConfig.Logger = logtest.NewLogger()
|
|
nodeSvc, err := service.NewTeleport(nodeConfig)
|
|
require.NoError(t, err)
|
|
require.NoError(t, nodeSvc.Start())
|
|
t.Cleanup(func() { require.NoError(t, nodeSvc.Close()) })
|
|
|
|
_, err = nodeSvc.WaitForEventTimeout(10*time.Second, service.TeleportReadyEvent)
|
|
require.NoError(t, err, "timeout waiting for node readiness")
|
|
|
|
// Verify that WaitForConnectMyComputerNodeJoin returned with no errors.
|
|
require.NoError(t, <-waitForNodeJoinErr)
|
|
}
|
|
|
|
func testDeleteConnectMyComputerNode(t *testing.T, pack *dbhelpers.DatabasePack) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
uuid := uuid.NewString()
|
|
userName := fmt.Sprintf("user-cmc-%s", uuid)
|
|
|
|
// Prepare a role with rules required to call DeleteConnectMyComputerNode.
|
|
ruleWithAllowRules, err := types.NewRole(fmt.Sprintf("cmc-allow-rules-%v", uuid),
|
|
types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindNode, services.RW()),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
userRoles := []types.Role{ruleWithAllowRules}
|
|
|
|
_, err = authtest.CreateUser(ctx, authServer, userName, userRoles...)
|
|
require.NoError(t, err)
|
|
|
|
// Log in as the new user.
|
|
creds, err := helpers.GenerateUserCreds(helpers.UserCredsRequest{
|
|
Process: pack.Root.Cluster.Process,
|
|
Username: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
tc := mustLogin(t, userName, pack, creds)
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
agentsDir := t.TempDir()
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: agentsDir,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
profileName, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
|
|
// Start the new node.
|
|
nodeConfig := newNodeConfig(t, "token", types.JoinMethodToken)
|
|
nodeConfig.SetAuthServerAddress(pack.Root.Cluster.Config.Auth.ListenAddr)
|
|
nodeConfig.DataDir = filepath.Join(agentsDir, profileName, "data")
|
|
nodeConfig.Logger = logtest.NewLogger()
|
|
nodeSvc, err := service.NewTeleport(nodeConfig)
|
|
require.NoError(t, err)
|
|
require.NoError(t, nodeSvc.Start())
|
|
t.Cleanup(func() { require.NoError(t, nodeSvc.Close()) })
|
|
|
|
nodeID, err := nodeSvc.WaitForHostID(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// waits for the node to be added
|
|
require.Eventually(t, func() bool {
|
|
_, err := authServer.GetNode(ctx, defaults.Namespace, nodeID)
|
|
return err == nil
|
|
}, time.Minute, time.Second, "waiting for node to join cluster")
|
|
|
|
// stop the node before attempting to remove it, to more closely resemble what's going to happen in production
|
|
err = nodeSvc.Close()
|
|
require.NoError(t, err)
|
|
|
|
// test
|
|
_, err = handler.DeleteConnectMyComputerNode(ctx, api.DeleteConnectMyComputerNodeRequest_builder{
|
|
RootClusterUri: uri.NewClusterURI(profileName).String(),
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
// waits for the node to be deleted
|
|
require.Eventually(t, func() bool {
|
|
_, err := authServer.GetNode(ctx, defaults.Namespace, nodeID)
|
|
return trace.IsNotFound(err)
|
|
}, time.Minute, time.Second, "waiting for node to be deleted")
|
|
}
|
|
|
|
func testListDatabaseUsersFromUnifiedResources(t *testing.T, pack *dbhelpers.DatabasePack) {
|
|
ctx := context.Background()
|
|
|
|
mustAddDBUserToUserRole := func(ctx context.Context, t *testing.T, cluster *helpers.TeleInstance, user, dbUser string) {
|
|
t.Helper()
|
|
authServer := cluster.Process.GetAuthServer()
|
|
roleName := services.RoleNameForUser(user)
|
|
role, err := authServer.GetRole(ctx, roleName)
|
|
require.NoError(t, err)
|
|
|
|
dbUsers := role.GetDatabaseUsers(types.Allow)
|
|
dbUsers = append(dbUsers, dbUser)
|
|
role.SetDatabaseUsers(types.Allow, dbUsers)
|
|
_, err = authServer.UpdateRole(ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
role, err := authServer.GetRole(ctx, roleName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, dbUsers, role.GetDatabaseUsers(types.Allow))
|
|
}, 10*time.Second, 100*time.Millisecond)
|
|
}
|
|
|
|
mustUpdateUserRoles := func(ctx context.Context, t *testing.T, cluster *helpers.TeleInstance, userName string, roles []string) {
|
|
t.Helper()
|
|
authServer := cluster.Process.GetAuthServer()
|
|
user, err := authServer.GetUser(ctx, userName, false /* withSecrets */)
|
|
require.NoError(t, err)
|
|
|
|
user.SetRoles(roles)
|
|
_, err = authServer.UpdateUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
user, err := authServer.GetUser(ctx, userName, false /* withSecrets */)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, roles, user.GetRoles())
|
|
}, 10*time.Second, 100*time.Millisecond)
|
|
}
|
|
|
|
rootClusterName, _, err := net.SplitHostPort(pack.Root.Cluster.Web)
|
|
require.NoError(t, err)
|
|
rootDatabaseURI := uri.NewClusterURI(rootClusterName).AppendDB(pack.Root.PostgresService.Name)
|
|
leafDatabaseURI := uri.NewClusterURI(rootClusterName).AppendLeafCluster(pack.Leaf.Cluster.Secrets.SiteName).AppendDB(pack.Leaf.PostgresService.Name)
|
|
|
|
rootDBUser := fmt.Sprintf("root-db-user-%s", uuid.NewString())
|
|
leafDBUser := fmt.Sprintf("leaf-db-user-%s", uuid.NewString())
|
|
leafDBUserWithAccessRequest := fmt.Sprintf("leaf-db-user-with-access-request-%s", uuid.NewString())
|
|
|
|
rootUserName := pack.Root.User.GetName()
|
|
leafUserName := pack.Leaf.User.GetName()
|
|
rootRoleName := services.RoleNameForUser(rootUserName)
|
|
|
|
tests := []struct {
|
|
name string
|
|
dbURI uri.ResourceURI
|
|
wantDBUser string
|
|
prepareRole func(ctx context.Context, t *testing.T)
|
|
createAccessRequest func(ctx context.Context, t *testing.T) string
|
|
}{
|
|
{
|
|
name: "root cluster",
|
|
dbURI: rootDatabaseURI,
|
|
wantDBUser: rootDBUser,
|
|
prepareRole: func(ctx context.Context, t *testing.T) {
|
|
mustAddDBUserToUserRole(ctx, t, pack.Root.Cluster, rootUserName, rootDBUser)
|
|
},
|
|
},
|
|
{
|
|
name: "leaf cluster",
|
|
dbURI: leafDatabaseURI,
|
|
wantDBUser: leafDBUser,
|
|
prepareRole: func(ctx context.Context, t *testing.T) {
|
|
mustAddDBUserToUserRole(ctx, t, pack.Leaf.Cluster, leafUserName, leafDBUser)
|
|
},
|
|
},
|
|
{
|
|
name: "leaf cluster with resource access request",
|
|
dbURI: leafDatabaseURI,
|
|
wantDBUser: leafDBUserWithAccessRequest,
|
|
// Remove role from root-user and move it to search_as_roles.
|
|
//
|
|
// root-user has access to leafDatabaseURI through the user:root-user role which gets mapped
|
|
// to a corresponding leaf cluster role.
|
|
// We want to create a resource access request for that database. To do this, we need to
|
|
// create a new role which lets root-user request the database.
|
|
prepareRole: func(ctx context.Context, t *testing.T) {
|
|
mustAddDBUserToUserRole(ctx, t, pack.Leaf.Cluster, leafUserName, leafDBUserWithAccessRequest)
|
|
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
|
|
// Create new role that lets root-user request the database.
|
|
requesterRole, err := types.NewRole(fmt.Sprintf("requester-%s", uuid.NewString()), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
SearchAsRoles: []string{rootRoleName},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
requesterRole, err = authServer.CreateRole(ctx, requesterRole)
|
|
require.NoError(t, err)
|
|
|
|
user, err := authServer.GetUser(ctx, rootUserName, false /* withSecrets */)
|
|
require.NoError(t, err)
|
|
|
|
// Delete rootRoleName from roles, add requester role. Restore original role set after test
|
|
// is done.
|
|
currentRoles := user.GetRoles()
|
|
t.Cleanup(func() { mustUpdateUserRoles(ctx, t, pack.Root.Cluster, rootUserName, currentRoles) })
|
|
mustUpdateUserRoles(ctx, t, pack.Root.Cluster, rootUserName, []string{requesterRole.GetName()})
|
|
},
|
|
createAccessRequest: func(ctx context.Context, t *testing.T) string {
|
|
req, err := services.NewAccessRequestWithResources(rootUserName, []string{rootRoleName}, []types.ResourceAccessID{
|
|
{
|
|
Id: types.ResourceID{
|
|
ClusterName: pack.Leaf.Cluster.Secrets.SiteName,
|
|
Kind: types.KindDatabase,
|
|
Name: pack.Leaf.PostgresService.Name,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
authServer := pack.Root.Cluster.Process.GetAuthServer()
|
|
req, err = authServer.CreateAccessRequestV2(ctx, req, tlsca.Identity{})
|
|
require.NoError(t, err)
|
|
|
|
err = authServer.SetAccessRequestState(ctx, types.AccessRequestUpdate{
|
|
RequestID: req.GetName(),
|
|
State: types.RequestState_APPROVED,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return req.GetName()
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
test.prepareRole(ctx, t)
|
|
var accessRequestID string
|
|
if test.createAccessRequest != nil {
|
|
accessRequestID = test.createAccessRequest(ctx, t)
|
|
|
|
if accessRequestID == "" {
|
|
require.FailNow(t, "createAccessRequest returned empty access request ID")
|
|
}
|
|
}
|
|
|
|
creds, err := helpers.GenerateUserCreds(helpers.UserCredsRequest{
|
|
Process: pack.Root.Cluster.Process,
|
|
Username: rootUserName,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tc := mustLogin(t, rootUserName, pack, creds)
|
|
|
|
storage, err := clusters.NewStorage(clusters.Config{
|
|
ClientStore: tc.ClientStore,
|
|
InsecureSkipVerify: tc.InsecureSkipVerify,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
daemonService, err := daemon.New(daemon.Config{
|
|
Storage: storage,
|
|
KubeconfigsDir: t.TempDir(),
|
|
AgentsDir: t.TempDir(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
daemonService.Stop()
|
|
})
|
|
|
|
handler, err := handler.New(
|
|
handler.Config{
|
|
DaemonService: daemonService,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
if accessRequestID != "" {
|
|
_, err := handler.AssumeRole(ctx, api.AssumeRoleRequest_builder{
|
|
RootClusterUri: test.dbURI.GetRootClusterURI().String(),
|
|
AccessRequestIds: []string{accessRequestID},
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
res, err := handler.ListUnifiedResources(ctx, api.ListUnifiedResourcesRequest_builder{
|
|
ClusterUri: test.dbURI.GetClusterURI().String(),
|
|
Kinds: []string{types.KindDatabase},
|
|
}.Build())
|
|
require.NoError(t, err)
|
|
|
|
var matchedDatabase *api.Database
|
|
for _, resource := range res.GetResources() {
|
|
database := resource.GetDatabase()
|
|
if database != nil && database.GetUri() == test.dbURI.String() {
|
|
matchedDatabase = database
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, matchedDatabase, "database %q not found in unified resources response", test.dbURI.String())
|
|
require.Contains(t, matchedDatabase.GetDatabaseUsers(), test.wantDBUser)
|
|
})
|
|
}
|
|
}
|
|
|
|
// mustLogin logs in as the given user by completely skipping the actual login flow and saving valid
|
|
// certs to disk. clusters.Storage can then be pointed to tc.ClientStore and daemon.Service can act as
|
|
// if the user was successfully logged in.
|
|
//
|
|
// This is faster than going through the actual process, but keep in mind that it might skip some
|
|
// vital steps. It should be used only for tests which don't depend on complex user setup and do not
|
|
// reissue certs or modify them in some other way.
|
|
func mustLogin(t *testing.T, userName string, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) *client.TeleportClient {
|
|
tc, err := pack.Root.Cluster.NewClientWithCreds(helpers.ClientConfig{
|
|
Login: userName,
|
|
Cluster: pack.Root.Cluster.Secrets.SiteName,
|
|
}, *creds)
|
|
require.NoError(t, err)
|
|
// Save the profile yaml file to disk as NewClientWithCreds doesn't do that by itself.
|
|
err = tc.SaveProfile(false /* makeCurrent */)
|
|
require.NoError(t, err)
|
|
return tc
|
|
}
|
|
|
|
type setupUserMFAFunc func(t *testing.T, userName string, tshdEventsService *mockTSHDEventsService) client.WebauthnLoginFunc
|
|
|
|
type mockTSHDEventsService struct {
|
|
api.UnimplementedTshdEventsServiceServer
|
|
sendPendingHeadlessAuthenticationCount atomic.Uint32
|
|
promptMFACallCount atomic.Uint32
|
|
}
|
|
|
|
func newMockTSHDEventsServiceServer(t *testing.T) (service *mockTSHDEventsService, addr string) {
|
|
t.Helper()
|
|
tshdEventsService := &mockTSHDEventsService{}
|
|
|
|
ls, err := net.Listen("tcp", "localhost:0")
|
|
require.NoError(t, err)
|
|
|
|
grpcServer := grpc.NewServer()
|
|
api.RegisterTshdEventsServiceServer(grpcServer, tshdEventsService)
|
|
|
|
serveErr := make(chan error)
|
|
go func() {
|
|
serveErr <- grpcServer.Serve(ls)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
grpcServer.GracefulStop()
|
|
|
|
// For test cases that did not send any grpc calls, test may finish
|
|
// before grpcServer.Serve is called and grpcServer.Serve will return
|
|
// grpc.ErrServerStopped.
|
|
err := <-serveErr
|
|
if !errors.Is(err, grpc.ErrServerStopped) {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
|
|
return tshdEventsService, ls.Addr().String()
|
|
}
|
|
|
|
func (c *mockTSHDEventsService) SendPendingHeadlessAuthentication(context.Context, *api.SendPendingHeadlessAuthenticationRequest) (*api.SendPendingHeadlessAuthenticationResponse, error) {
|
|
c.sendPendingHeadlessAuthenticationCount.Add(1)
|
|
return &api.SendPendingHeadlessAuthenticationResponse{}, nil
|
|
}
|
|
|
|
func (c *mockTSHDEventsService) PromptMFA(context.Context, *api.PromptMFARequest) (*api.PromptMFAResponse, error) {
|
|
c.promptMFACallCount.Add(1)
|
|
|
|
// PromptMFAResponse returns the TOTP code, so PromptMFA itself
|
|
// needs to be implemented only once we need to test TOTP MFA.
|
|
return nil, trace.NotImplemented("mockTSHDEventsService does not implement PromptMFA")
|
|
}
|