mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-01 05:16:43 +08:00
The Okta service and client have been introduced. This will allow for the maintenance of Okta objects via our gRPC server and auth client. Some additional improvements have been made so that the local okta service now better reflects the gRPC definition. Additionally, the gRPC authorization mechanism has been lifted out of `lib/auth/grpcserver.go` and moved into a common authorizer. Finally, the ability to check access to verbs has been lifted out of the `pluginsv1` service into that same authorizer. This will hopefully provide a good base for separate gRPC services in the future.
3676 lines
110 KiB
Go
3676 lines
110 KiB
Go
/*
|
|
Copyright 2021 Gravitational, Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base32"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/gravitational/trace/trail"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/pquerna/otp"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
otlpcommonv1 "go.opentelemetry.io/proto/otlp/common/v1"
|
|
otlpresourcev1 "go.opentelemetry.io/proto/otlp/resource/v1"
|
|
otlptracev1 "go.opentelemetry.io/proto/otlp/trace/v1"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/testing/protocmp"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/metadata"
|
|
"github.com/gravitational/teleport/api/observability/tracing"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/types/installers"
|
|
apiutils "github.com/gravitational/teleport/api/utils"
|
|
"github.com/gravitational/teleport/api/utils/sshutils"
|
|
"github.com/gravitational/teleport/lib/auth/mocku2f"
|
|
"github.com/gravitational/teleport/lib/auth/testauthority"
|
|
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
|
|
"github.com/gravitational/teleport/lib/authz"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/modules"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
)
|
|
|
|
func TestMFADeviceManagement(t *testing.T) {
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
clock := srv.Clock().(clockwork.FakeClock)
|
|
|
|
// Enable MFA support.
|
|
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
const webOrigin = "https://localhost" // matches RPID above
|
|
require.NoError(t, err)
|
|
err = srv.Auth().SetAuthPreference(ctx, authPref)
|
|
require.NoError(t, err)
|
|
|
|
// Create a fake user.
|
|
user, _, err := CreateUserAndRole(srv.Auth(), "mfa-user", []string{"role"}, nil)
|
|
require.NoError(t, err)
|
|
cl, err := srv.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
// No MFA devices should exist for a new user.
|
|
resp, err := cl.GetMFADevices(ctx, &proto.GetMFADevicesRequest{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, resp.Devices)
|
|
|
|
// Add one device of each kind
|
|
devs := addOneOfEachMFADevice(t, cl, clock, webOrigin)
|
|
|
|
// Run AddMFADevice tests, including adding additional devices and failures.
|
|
webKey2, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
webKey2.PreferRPID = true
|
|
const webDev2Name = "webauthn2"
|
|
const pwdlessDevName = "pwdless"
|
|
|
|
addTests := []struct {
|
|
desc string
|
|
opts mfaAddTestOpts
|
|
}{
|
|
{
|
|
desc: "fail TOTP auth challenge",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: "fail-dev",
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
},
|
|
authHandler: func(t *testing.T, req *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
require.NotNil(t, req.TOTP)
|
|
|
|
// Respond to challenge using an unregistered TOTP device,
|
|
// which should fail the auth challenge.
|
|
badDev, err := totp.Generate(totp.GenerateOpts{Issuer: "Teleport", AccountName: user.GetName()})
|
|
require.NoError(t, err)
|
|
code, err := totp.GenerateCode(badDev.Secret(), clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
return &proto.MFAAuthenticateResponse{Response: &proto.MFAAuthenticateResponse_TOTP{TOTP: &proto.TOTPResponse{
|
|
Code: code,
|
|
}}}
|
|
},
|
|
checkAuthErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "fail a TOTP registration challenge",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: "fail-dev",
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_TOTP,
|
|
},
|
|
authHandler: devs.totpAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
registerHandler: func(t *testing.T, req *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
|
totpRegisterChallenge := req.GetTOTP()
|
|
require.NotEmpty(t, totpRegisterChallenge)
|
|
require.Equal(t, totpRegisterChallenge.Algorithm, otp.AlgorithmSHA1.String())
|
|
// Use the wrong secret for registration, causing server
|
|
// validation to fail.
|
|
code, err := totp.GenerateCodeCustom(base32.StdEncoding.EncodeToString([]byte("wrong-secret")), clock.Now(), totp.ValidateOpts{
|
|
Period: uint(totpRegisterChallenge.PeriodSeconds),
|
|
Digits: otp.Digits(totpRegisterChallenge.Digits),
|
|
Algorithm: otp.AlgorithmSHA1,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_TOTP{TOTP: &proto.TOTPRegisterResponse{
|
|
Code: code,
|
|
}},
|
|
}
|
|
},
|
|
checkRegisterErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "add a second webauthn device",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: webDev2Name,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
registerHandler: func(t *testing.T, challenge *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
|
ccr, err := webKey2.SignCredentialCreation(webOrigin, wanlib.CredentialCreationFromProto(challenge.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
|
|
return &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialCreationResponseToProto(ccr),
|
|
},
|
|
}
|
|
},
|
|
checkRegisterErr: require.NoError,
|
|
},
|
|
},
|
|
{
|
|
desc: "fail a webauthn auth challenge",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: "webauthn-1512000",
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
},
|
|
authHandler: func(t *testing.T, challenge *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
require.NotNil(t, challenge.WebauthnChallenge) // webauthn enabled
|
|
|
|
// Sign challenge with an unknown device.
|
|
key, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
key.PreferRPID = true
|
|
key.IgnoreAllowedCredentials = true
|
|
resp, err := key.SignAssertion(webOrigin, wanlib.CredentialAssertionFromProto(challenge.WebauthnChallenge))
|
|
require.NoError(t, err)
|
|
return &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialAssertionResponseToProto(resp),
|
|
},
|
|
}
|
|
},
|
|
checkAuthErr: func(t require.TestingT, err error, i ...interface{}) {
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.PermissionDenied, status.Code(err))
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "fail a webauthn registration challenge",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: "webauthn-1512000",
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
registerHandler: func(t *testing.T, challenge *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
|
require.NotNil(t, challenge.GetWebauthn())
|
|
|
|
key, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
key.PreferRPID = true
|
|
|
|
ccr, err := key.SignCredentialCreation(
|
|
"http://badorigin.com" /* origin */, wanlib.CredentialCreationFromProto(challenge.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
return &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialCreationResponseToProto(ccr),
|
|
},
|
|
}
|
|
},
|
|
checkRegisterErr: func(t require.TestingT, err error, i ...interface{}) {
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "add passwordless device",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: pwdlessDevName,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
registerHandler: func(t *testing.T, challenge *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
|
require.NotNil(t, challenge.GetWebauthn(), "WebAuthn challenge cannot be nil")
|
|
|
|
key, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
key.PreferRPID = true
|
|
key.SetPasswordless()
|
|
|
|
ccr, err := key.SignCredentialCreation(webOrigin, wanlib.CredentialCreationFromProto(challenge.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
|
|
return &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialCreationResponseToProto(ccr),
|
|
},
|
|
}
|
|
},
|
|
checkRegisterErr: require.NoError,
|
|
assertRegisteredDev: func(t *testing.T, dev *types.MFADevice) {
|
|
// Do a few simple device checks - lib/auth/webauthn goes in depth.
|
|
require.NotNil(t, dev.GetWebauthn(), "WebAuthnDevice cannot be nil")
|
|
require.True(t, true, dev.GetWebauthn().ResidentKey, "ResidentKey should be set to true")
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range addTests {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
testAddMFADevice(ctx, t, cl, tt.opts)
|
|
})
|
|
}
|
|
|
|
// Check that all new devices are registered.
|
|
resp, err = cl.GetMFADevices(ctx, &proto.GetMFADevicesRequest{})
|
|
require.NoError(t, err)
|
|
deviceNames := make([]string, 0, len(resp.Devices))
|
|
deviceIDs := make(map[string]string)
|
|
for _, dev := range resp.Devices {
|
|
deviceNames = append(deviceNames, dev.GetName())
|
|
deviceIDs[dev.GetName()] = dev.Id
|
|
}
|
|
sort.Strings(deviceNames)
|
|
require.Equal(t, deviceNames, []string{pwdlessDevName, devs.TOTPName, devs.WebName, webDev2Name})
|
|
|
|
// Delete several of the MFA devices.
|
|
deleteTests := []struct {
|
|
desc string
|
|
opts mfaDeleteTestOpts
|
|
}{
|
|
{
|
|
desc: "fail to delete an unknown device",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: "unknown-dev",
|
|
},
|
|
authHandler: devs.totpAuthHandler,
|
|
checkErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "fail a TOTP auth challenge",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.TOTPName,
|
|
},
|
|
authHandler: func(t *testing.T, req *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
require.NotNil(t, req.TOTP)
|
|
|
|
// Respond to challenge using an unregistered TOTP device,
|
|
// which should fail the auth challenge.
|
|
badDev, err := totp.Generate(totp.GenerateOpts{Issuer: "Teleport", AccountName: user.GetName()})
|
|
require.NoError(t, err)
|
|
code, err := totp.GenerateCode(badDev.Secret(), clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
return &proto.MFAAuthenticateResponse{Response: &proto.MFAAuthenticateResponse_TOTP{TOTP: &proto.TOTPResponse{
|
|
Code: code,
|
|
}}}
|
|
},
|
|
checkErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "fail a webauthn auth challenge",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.WebName,
|
|
},
|
|
authHandler: func(t *testing.T, challenge *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
require.NotNil(t, challenge.WebauthnChallenge)
|
|
|
|
// Sign challenge with an unknown device.
|
|
key, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
key.PreferRPID = true
|
|
key.IgnoreAllowedCredentials = true
|
|
resp, err := key.SignAssertion(webOrigin, wanlib.CredentialAssertionFromProto(challenge.WebauthnChallenge))
|
|
require.NoError(t, err)
|
|
return &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialAssertionResponseToProto(resp),
|
|
},
|
|
}
|
|
},
|
|
checkErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "delete TOTP device by name",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.TOTPName,
|
|
},
|
|
authHandler: devs.totpAuthHandler,
|
|
checkErr: require.NoError,
|
|
},
|
|
},
|
|
{
|
|
desc: "delete pwdless device by name",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: pwdlessDevName,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkErr: require.NoError,
|
|
},
|
|
},
|
|
{
|
|
desc: "delete webauthn device by name",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.WebName,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkErr: require.NoError,
|
|
},
|
|
},
|
|
{
|
|
desc: "delete webauthn device by ID",
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: deviceIDs[webDev2Name],
|
|
},
|
|
authHandler: func(t *testing.T, challenge *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
resp, err := webKey2.SignAssertion(
|
|
webOrigin, wanlib.CredentialAssertionFromProto(challenge.WebauthnChallenge))
|
|
require.NoError(t, err)
|
|
return &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialAssertionResponseToProto(resp),
|
|
},
|
|
}
|
|
},
|
|
checkErr: require.NoError,
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range deleteTests {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
testDeleteMFADevice(ctx, t, cl, tt.opts)
|
|
})
|
|
}
|
|
|
|
// Check the remaining number of devices
|
|
resp, err = cl.GetMFADevices(ctx, &proto.GetMFADevicesRequest{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, resp.Devices)
|
|
}
|
|
|
|
type mfaDevices struct {
|
|
clock clockwork.Clock
|
|
webOrigin string
|
|
|
|
TOTPName, TOTPSecret string
|
|
WebName string
|
|
WebKey *mocku2f.Key
|
|
}
|
|
|
|
func (d *mfaDevices) totpAuthHandler(t *testing.T, challenge *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
require.NotNil(t, challenge.TOTP)
|
|
|
|
if c, ok := d.clock.(clockwork.FakeClock); ok {
|
|
c.Advance(30 * time.Second)
|
|
}
|
|
code, err := totp.GenerateCode(d.TOTPSecret, d.clock.Now())
|
|
require.NoError(t, err)
|
|
return &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_TOTP{
|
|
TOTP: &proto.TOTPResponse{
|
|
Code: code,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (d *mfaDevices) webAuthHandler(t *testing.T, challenge *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
require.NotNil(t, challenge.WebauthnChallenge)
|
|
|
|
resp, err := d.WebKey.SignAssertion(
|
|
d.webOrigin, wanlib.CredentialAssertionFromProto(challenge.WebauthnChallenge))
|
|
require.NoError(t, err)
|
|
return &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialAssertionResponseToProto(resp),
|
|
},
|
|
}
|
|
}
|
|
|
|
func addOneOfEachMFADevice(t *testing.T, cl *Client, clock clockwork.Clock, origin string) mfaDevices {
|
|
const totpName = "totp-dev"
|
|
const webName = "webauthn-dev"
|
|
devs := mfaDevices{
|
|
clock: clock,
|
|
webOrigin: origin,
|
|
TOTPName: totpName,
|
|
WebName: webName,
|
|
}
|
|
|
|
var err error
|
|
devs.WebKey, err = mocku2f.Create()
|
|
require.NoError(t, err)
|
|
devs.WebKey.PreferRPID = true
|
|
|
|
// Add MFA devices of all kinds.
|
|
ctx := context.Background()
|
|
for _, test := range []struct {
|
|
name string
|
|
opts mfaAddTestOpts
|
|
}{
|
|
{
|
|
name: "TOTP device",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: totpName,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_TOTP,
|
|
},
|
|
authHandler: func(t *testing.T, req *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
// Empty for first device.
|
|
return &proto.MFAAuthenticateResponse{}
|
|
},
|
|
checkAuthErr: require.NoError,
|
|
registerHandler: func(t *testing.T, challenge *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
|
require.NotEmpty(t, challenge.GetTOTP())
|
|
require.Equal(t, challenge.GetTOTP().Algorithm, otp.AlgorithmSHA1.String())
|
|
|
|
devs.TOTPSecret = challenge.GetTOTP().Secret
|
|
code, err := totp.GenerateCodeCustom(devs.TOTPSecret, clock.Now(), totp.ValidateOpts{
|
|
Period: uint(challenge.GetTOTP().PeriodSeconds),
|
|
Digits: otp.Digits(challenge.GetTOTP().Digits),
|
|
Algorithm: otp.AlgorithmSHA1,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_TOTP{
|
|
TOTP: &proto.TOTPRegisterResponse{
|
|
Code: code,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
checkRegisterErr: require.NoError,
|
|
assertRegisteredDev: func(t *testing.T, got *types.MFADevice) {
|
|
want, err := services.NewTOTPDevice(totpName, devs.TOTPSecret, clock.Now())
|
|
want.Id = got.Id
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(want, got))
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "Webauthn device",
|
|
opts: mfaAddTestOpts{
|
|
initReq: &proto.AddMFADeviceRequestInit{
|
|
DeviceName: webName,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
},
|
|
authHandler: devs.totpAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
registerHandler: func(t *testing.T, challenge *proto.MFARegisterChallenge) *proto.MFARegisterResponse {
|
|
require.NotNil(t, challenge.GetWebauthn())
|
|
|
|
ccr, err := devs.WebKey.SignCredentialCreation(origin, wanlib.CredentialCreationFromProto(challenge.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
return &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wanlib.CredentialCreationResponseToProto(ccr),
|
|
},
|
|
}
|
|
},
|
|
checkRegisterErr: require.NoError,
|
|
assertRegisteredDev: func(t *testing.T, got *types.MFADevice) {
|
|
// MFADevice device asserted in its entirety by lib/auth/webauthn
|
|
// tests, a simple check suffices here.
|
|
require.Equal(t, devs.WebKey.KeyHandle, got.GetWebauthn().CredentialId)
|
|
},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
testAddMFADevice(ctx, t, cl, test.opts)
|
|
})
|
|
}
|
|
return devs
|
|
}
|
|
|
|
type mfaAddTestOpts struct {
|
|
initReq *proto.AddMFADeviceRequestInit
|
|
authHandler func(*testing.T, *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse
|
|
checkAuthErr require.ErrorAssertionFunc
|
|
registerHandler func(*testing.T, *proto.MFARegisterChallenge) *proto.MFARegisterResponse
|
|
checkRegisterErr require.ErrorAssertionFunc
|
|
assertRegisteredDev func(*testing.T, *types.MFADevice)
|
|
}
|
|
|
|
func testAddMFADevice(ctx context.Context, t *testing.T, cl *Client, opts mfaAddTestOpts) {
|
|
addStream, err := cl.AddMFADevice(ctx)
|
|
require.NoError(t, err)
|
|
err = addStream.Send(&proto.AddMFADeviceRequest{Request: &proto.AddMFADeviceRequest_Init{Init: opts.initReq}})
|
|
require.NoError(t, err)
|
|
|
|
authChallenge, err := addStream.Recv()
|
|
require.NoError(t, err)
|
|
authResp := opts.authHandler(t, authChallenge.GetExistingMFAChallenge())
|
|
err = addStream.Send(&proto.AddMFADeviceRequest{Request: &proto.AddMFADeviceRequest_ExistingMFAResponse{ExistingMFAResponse: authResp}})
|
|
require.NoError(t, err)
|
|
|
|
registerChallenge, err := addStream.Recv()
|
|
opts.checkAuthErr(t, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
registerResp := opts.registerHandler(t, registerChallenge.GetNewMFARegisterChallenge())
|
|
err = addStream.Send(&proto.AddMFADeviceRequest{Request: &proto.AddMFADeviceRequest_NewMFARegisterResponse{NewMFARegisterResponse: registerResp}})
|
|
require.NoError(t, err)
|
|
|
|
registerAck, err := addStream.Recv()
|
|
opts.checkRegisterErr(t, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if opts.assertRegisteredDev != nil {
|
|
opts.assertRegisteredDev(t, registerAck.GetAck().GetDevice())
|
|
}
|
|
|
|
require.NoError(t, addStream.CloseSend())
|
|
}
|
|
|
|
type mfaDeleteTestOpts struct {
|
|
initReq *proto.DeleteMFADeviceRequestInit
|
|
authHandler func(*testing.T, *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse
|
|
checkErr require.ErrorAssertionFunc
|
|
}
|
|
|
|
func testDeleteMFADevice(ctx context.Context, t *testing.T, cl *Client, opts mfaDeleteTestOpts) {
|
|
deleteStream, err := cl.DeleteMFADevice(ctx)
|
|
require.NoError(t, err)
|
|
err = deleteStream.Send(&proto.DeleteMFADeviceRequest{Request: &proto.DeleteMFADeviceRequest_Init{Init: opts.initReq}})
|
|
require.NoError(t, err)
|
|
|
|
authChallenge, err := deleteStream.Recv()
|
|
require.NoError(t, err)
|
|
authResp := opts.authHandler(t, authChallenge.GetMFAChallenge())
|
|
err = deleteStream.Send(&proto.DeleteMFADeviceRequest{Request: &proto.DeleteMFADeviceRequest_MFAResponse{MFAResponse: authResp}})
|
|
require.NoError(t, err)
|
|
|
|
deleteAck, err := deleteStream.Recv()
|
|
opts.checkErr(t, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
deleted := deleteAck.GetAck().GetDevice()
|
|
require.NotNil(t, deleted, "deleted device in ack message is nil")
|
|
require.NotEmpty(t, deleted.Id, "deleted device.Id in ack message is empty")
|
|
require.NotEmpty(t, deleted.GetName(), "deleted device.Name in ack message is empty")
|
|
// opts.initReq.DeviceName can be either the device name or ID, so check if
|
|
// either matches the deleted device.
|
|
wantName := []string{
|
|
deleted.Id,
|
|
deleted.GetName(),
|
|
}
|
|
require.Contains(t, wantName, opts.initReq.DeviceName)
|
|
require.NoError(t, deleteStream.CloseSend())
|
|
}
|
|
|
|
func TestDeleteLastMFADevice(t *testing.T) {
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
// Enable MFA support.
|
|
authSpec := &types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
}
|
|
authPref, err := types.NewAuthPreference(*authSpec)
|
|
|
|
const webOrigin = "https://localhost" // matches RPID above
|
|
require.NoError(t, err)
|
|
auth := srv.Auth()
|
|
err = auth.SetAuthPreference(ctx, authPref)
|
|
require.NoError(t, err)
|
|
|
|
// Create a fake user.
|
|
user, _, err := CreateUserAndRole(auth, "mfa-user", []string{"role"}, nil)
|
|
require.NoError(t, err)
|
|
cl, err := srv.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
// Add devices
|
|
devs := addOneOfEachMFADevice(t, cl, srv.Clock(), webOrigin)
|
|
|
|
tests := []struct {
|
|
name string
|
|
secondFactor constants.SecondFactorType
|
|
opts mfaDeleteTestOpts
|
|
}{
|
|
{
|
|
name: "NOK sf=OTP trying to delete last OTP device",
|
|
secondFactor: constants.SecondFactorOTP,
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.TOTPName,
|
|
},
|
|
authHandler: devs.totpAuthHandler,
|
|
checkErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
name: "NOK sf=Webauthn trying to delete last Webauthn device",
|
|
secondFactor: constants.SecondFactorWebauthn,
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.WebName,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
name: "OK delete OTP device",
|
|
secondFactor: constants.SecondFactorOn,
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.TOTPName,
|
|
},
|
|
authHandler: devs.totpAuthHandler,
|
|
checkErr: require.NoError,
|
|
},
|
|
},
|
|
{
|
|
name: "NOK sf=on trying to delete last MFA device",
|
|
secondFactor: constants.SecondFactorOn,
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.WebName,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
name: "OK sf=optional delete last device (webauthn)",
|
|
secondFactor: constants.SecondFactorOptional,
|
|
opts: mfaDeleteTestOpts{
|
|
initReq: &proto.DeleteMFADeviceRequestInit{
|
|
DeviceName: devs.WebName,
|
|
},
|
|
authHandler: devs.webAuthHandler,
|
|
checkErr: require.NoError,
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
// Update second factor settings, if necessary.
|
|
cap, err := auth.GetAuthPreference(ctx)
|
|
require.NoError(t, err)
|
|
if cap.GetSecondFactor() != test.secondFactor {
|
|
authSpec.SecondFactor = test.secondFactor
|
|
newCAP, err := types.NewAuthPreference(*authSpec)
|
|
require.NoError(t, err)
|
|
require.NoError(t, auth.SetAuthPreference(ctx, newCAP))
|
|
}
|
|
|
|
testDeleteMFADevice(ctx, t, cl, test.opts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateAppSession_deviceExtensions(t *testing.T) {
|
|
ctx := context.Background()
|
|
testServer := newTestTLSServer(t)
|
|
authServer := testServer.Auth()
|
|
|
|
// Create an user for testing.
|
|
user, _, err := CreateUserAndRole(authServer, "llama", []string{"llama"}, nil)
|
|
require.NoError(t, err, "CreateUserAndRole failed")
|
|
|
|
// Register an application.
|
|
app, err := types.NewAppV3(
|
|
types.Metadata{
|
|
Name: "llamaapp",
|
|
}, types.AppSpecV3{
|
|
URI: "http://localhost:8080",
|
|
PublicAddr: "llamaapp.example.com",
|
|
})
|
|
require.NoError(t, err, "NewAppV3 failed")
|
|
appServer, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err, "NewAppServerV3FromApp failed")
|
|
_, err = authServer.UpsertApplicationServer(ctx, appServer)
|
|
require.NoError(t, err, "UpsertApplicationServer failed")
|
|
|
|
wantExtensions := &tlsca.DeviceExtensions{
|
|
DeviceID: "device1",
|
|
AssetTag: "assettag1",
|
|
CredentialID: "credentialid1",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
modifyUser func(u *TestIdentity)
|
|
assertCert func(t *testing.T, cert *x509.Certificate)
|
|
}{
|
|
{
|
|
name: "no device extensions",
|
|
// Absence of errors is enough here, this is mostly to make sure the base
|
|
// scenario works.
|
|
},
|
|
{
|
|
name: "user with device extensions",
|
|
modifyUser: func(u *TestIdentity) {
|
|
lu := u.I.(authz.LocalUser)
|
|
lu.Identity.DeviceExtensions = *wantExtensions
|
|
u.I = lu
|
|
},
|
|
assertCert: func(t *testing.T, cert *x509.Certificate) {
|
|
gotIdentity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
require.NoError(t, err, "FromSubject failed")
|
|
|
|
if diff := cmp.Diff(*wantExtensions, gotIdentity.DeviceExtensions, protocmp.Transform()); diff != "" {
|
|
t.Errorf("DeviceExtensions mismatch (-want +got)\n%s", diff)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
u := TestUser(user.GetName())
|
|
if test.modifyUser != nil {
|
|
test.modifyUser(&u)
|
|
}
|
|
|
|
userClient, err := testServer.NewClient(u)
|
|
require.NoError(t, err, "NewClient failed")
|
|
|
|
session, err := userClient.CreateAppSession(ctx, types.CreateAppSessionRequest{
|
|
Username: user.GetName(),
|
|
PublicAddr: app.GetPublicAddr(),
|
|
ClusterName: testServer.ClusterName(),
|
|
})
|
|
require.NoError(t, err, "CreateAppSession failed")
|
|
|
|
block, _ := pem.Decode(session.GetTLSCert())
|
|
require.NotNil(t, block, "Decode failed")
|
|
gotCert, err := x509.ParseCertificate(block.Bytes)
|
|
require.NoError(t, err, "ParserCertificate failed")
|
|
|
|
if test.assertCert != nil {
|
|
test.assertCert(t, gotCert)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateUserCerts_deviceExtensions(t *testing.T) {
|
|
ctx := context.Background()
|
|
testServer := newTestTLSServer(t)
|
|
|
|
// Create an user for testing.
|
|
user, _, err := CreateUserAndRole(testServer.Auth(), "llama", []string{"llama"}, nil)
|
|
require.NoError(t, err, "CreateUserAndRole failed")
|
|
|
|
_, pub, err := testauthority.New().GenerateKeyPair()
|
|
require.NoError(t, err, "GenerateKeyPair failed")
|
|
|
|
wantExtensions := &tlsca.DeviceExtensions{
|
|
DeviceID: "device1",
|
|
AssetTag: "assettag1",
|
|
CredentialID: "credentialid1",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
modifyUser func(u *TestIdentity)
|
|
assertCert func(t *testing.T, cert *x509.Certificate)
|
|
}{
|
|
{
|
|
name: "no device extensions",
|
|
// Absence of errors is enough here, this is mostly to make sure the base
|
|
// scenario works.
|
|
},
|
|
{
|
|
name: "user with device extensions",
|
|
modifyUser: func(u *TestIdentity) {
|
|
lu := u.I.(authz.LocalUser)
|
|
lu.Identity.DeviceExtensions = *wantExtensions
|
|
u.I = lu
|
|
},
|
|
assertCert: func(t *testing.T, cert *x509.Certificate) {
|
|
gotIdentity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
require.NoError(t, err, "FromSubject failed")
|
|
|
|
if diff := cmp.Diff(*wantExtensions, gotIdentity.DeviceExtensions, protocmp.Transform()); diff != "" {
|
|
t.Errorf("DeviceExtensions mismatch (-want +got)\n%s", diff)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
u := TestUser(user.GetName())
|
|
if test.modifyUser != nil {
|
|
test.modifyUser(&u)
|
|
}
|
|
|
|
userClient, err := testServer.NewClient(u)
|
|
require.NoError(t, err, "NewClient failed")
|
|
|
|
resp, err := userClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
Expires: testServer.Clock().Now().Add(1 * time.Hour),
|
|
})
|
|
require.NoError(t, err, "GenerateUserCerts failed")
|
|
|
|
block, _ := pem.Decode(resp.TLS)
|
|
require.NotNil(t, block, "Decode failed")
|
|
gotCert, err := x509.ParseCertificate(block.Bytes)
|
|
require.NoError(t, err, "ParserCertificate failed")
|
|
|
|
if test.assertCert != nil {
|
|
test.assertCert(t, gotCert)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateUserCerts_deviceAuthz(t *testing.T) {
|
|
modules.SetTestModules(t, &modules.TestModules{
|
|
TestBuildType: modules.BuildEnterprise, // required for Device Trust.
|
|
})
|
|
|
|
testServer := newTestTLSServer(t)
|
|
|
|
ctx := context.Background()
|
|
clock := testServer.Clock()
|
|
clusterName := testServer.ClusterName()
|
|
authServer := testServer.Auth()
|
|
|
|
// Create a user for testing.
|
|
user, _, err := CreateUserAndRole(testServer.Auth(), "llama", []string{"llama"}, nil)
|
|
require.NoError(t, err, "CreateUserAndRole failed")
|
|
username := user.GetName()
|
|
|
|
// Create clients with and without device extensions.
|
|
clientWithoutDevice, err := testServer.NewClient(TestUser(username))
|
|
require.NoError(t, err, "NewClient failed")
|
|
|
|
clientWithDevice, err := testServer.NewClient(
|
|
TestUserWithDeviceExtensions(username, tlsca.DeviceExtensions{
|
|
DeviceID: "deviceid1",
|
|
AssetTag: "assettag1",
|
|
CredentialID: "credentialid1",
|
|
}))
|
|
require.NoError(t, err, "NewClient failed")
|
|
|
|
// updateAuthPref is a helper used throughout the test.
|
|
updateAuthPref := func(t *testing.T, modify func(ap types.AuthPreference)) {
|
|
authPref, err := authServer.GetAuthPreference(ctx)
|
|
require.NoError(t, err, "GetAuthPreference failed")
|
|
|
|
modify(authPref)
|
|
|
|
require.NoError(t,
|
|
authServer.SetAuthPreference(ctx, authPref),
|
|
"SetAuthPreference failed")
|
|
}
|
|
|
|
// Register MFA devices for the user.
|
|
// Required to issue certificates with MFA.
|
|
const rpID = "localhost"
|
|
const origin = "https://" + rpID + ":3080" // matches RPID.
|
|
updateAuthPref(t, func(authPref types.AuthPreference) {
|
|
authPref.SetSecondFactor(constants.SecondFactorOptional)
|
|
authPref.SetWebauthn(&types.Webauthn{
|
|
RPID: "localhost",
|
|
})
|
|
})
|
|
mfaDevices := addOneOfEachMFADevice(t, clientWithoutDevice, clock, origin)
|
|
|
|
// Create a public key for UserCertsRequest.
|
|
_, pub, err := testauthority.New().GenerateKeyPair()
|
|
require.NoError(t, err, "GenerateKeyPair failed")
|
|
|
|
expires := clock.Now().Add(1 * time.Hour)
|
|
sshReq := proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: username,
|
|
Expires: expires,
|
|
RouteToCluster: clusterName,
|
|
NodeName: "mynode",
|
|
Usage: proto.UserCertsRequest_SSH,
|
|
}
|
|
appReq := proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: username,
|
|
Expires: expires,
|
|
RouteToCluster: clusterName,
|
|
Usage: proto.UserCertsRequest_App,
|
|
RouteToApp: proto.RouteToApp{
|
|
Name: "hello",
|
|
SessionID: "mysessionid",
|
|
PublicAddr: "hello.cluster.dev",
|
|
ClusterName: clusterName,
|
|
},
|
|
}
|
|
winReq := proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: username,
|
|
Expires: expires,
|
|
RouteToCluster: clusterName,
|
|
Usage: proto.UserCertsRequest_WindowsDesktop,
|
|
RouteToWindowsDesktop: proto.RouteToWindowsDesktop{
|
|
WindowsDesktop: "mydesktop",
|
|
Login: username,
|
|
},
|
|
}
|
|
|
|
assertSuccess := func(t *testing.T, err error) {
|
|
assert.NoError(t, err, "GenerateUserCerts error mismatch")
|
|
}
|
|
assertAccessDenied := func(t *testing.T, err error) {
|
|
assert.True(t, trace.IsAccessDenied(err), "GenerateUserCerts error mismatch, got=%v (%T), want trace.AccessDeniedError", err, err)
|
|
}
|
|
|
|
// generateCertsMFA is used to run the MFA-aware, streaming certificate
|
|
// issuance ceremony.
|
|
generateCertsMFA := func(t *testing.T, client *Client, req proto.UserCertsRequest) (cert *proto.SingleUseUserCert, err error) {
|
|
defer func() {
|
|
// Translate gRPC to trace errors, as our clients do.
|
|
err = trail.FromGRPC(err)
|
|
}()
|
|
|
|
stream, err := client.GenerateUserSingleUseCerts(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := stream.Send(&proto.UserSingleUseCertsRequest{
|
|
Request: &proto.UserSingleUseCertsRequest_Init{
|
|
Init: &req,
|
|
},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := stream.Recv()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mfaResp := mfaDevices.webAuthHandler(t, resp.GetMFAChallenge())
|
|
|
|
if err := stream.Send(&proto.UserSingleUseCertsRequest{
|
|
Request: &proto.UserSingleUseCertsRequest_MFAResponse{
|
|
MFAResponse: mfaResp,
|
|
},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = stream.Recv()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert = resp.GetCert()
|
|
if cert == nil {
|
|
return nil, fmt.Errorf("unexpected response, cert is nil: %v", resp)
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
clusterDeviceMode string
|
|
client *Client
|
|
req proto.UserCertsRequest
|
|
skipGenerateUserCertsRPC bool // aka non-MFA issuance.
|
|
skipSingleUseCertsRPC bool // aka MFA/streaming issuance.
|
|
assertErr func(t *testing.T, err error)
|
|
}{
|
|
{
|
|
name: "mode=optional without extensions",
|
|
clusterDeviceMode: constants.DeviceTrustModeOptional,
|
|
client: clientWithoutDevice,
|
|
req: sshReq,
|
|
assertErr: assertSuccess,
|
|
},
|
|
{
|
|
name: "mode=optional with extensions",
|
|
clusterDeviceMode: constants.DeviceTrustModeOptional,
|
|
client: clientWithDevice,
|
|
req: sshReq,
|
|
assertErr: assertSuccess,
|
|
},
|
|
{
|
|
name: "nok: mode=required without extensions",
|
|
clusterDeviceMode: constants.DeviceTrustModeRequired,
|
|
client: clientWithoutDevice,
|
|
req: sshReq,
|
|
assertErr: assertAccessDenied,
|
|
},
|
|
{
|
|
name: "mode=required with extensions",
|
|
clusterDeviceMode: constants.DeviceTrustModeRequired,
|
|
client: clientWithDevice,
|
|
req: sshReq,
|
|
assertErr: assertSuccess,
|
|
},
|
|
{
|
|
name: "mode=required ignores App Access requests (non-MFA)",
|
|
clusterDeviceMode: constants.DeviceTrustModeRequired,
|
|
client: clientWithoutDevice,
|
|
req: appReq,
|
|
skipSingleUseCertsRPC: true,
|
|
assertErr: assertSuccess,
|
|
},
|
|
{
|
|
// Tracked here because, if this changes, then the scenario should be the
|
|
// same as the one above.
|
|
name: "GenerateUserSingleUseCerts does not allow App usage",
|
|
clusterDeviceMode: constants.DeviceTrustModeRequired,
|
|
client: clientWithoutDevice,
|
|
req: appReq,
|
|
skipGenerateUserCertsRPC: true,
|
|
assertErr: func(t *testing.T, err error) {
|
|
assert.ErrorContains(t, err, "app access certificates", "GenerateUserSingleUseCerts expected to fail for usage=App")
|
|
},
|
|
},
|
|
{
|
|
name: "mode=required ignores Desktop Access requests",
|
|
clusterDeviceMode: constants.DeviceTrustModeRequired,
|
|
client: clientWithoutDevice,
|
|
req: winReq,
|
|
assertErr: assertSuccess,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
updateAuthPref(t, func(ap types.AuthPreference) {
|
|
ap.SetDeviceTrust(&types.DeviceTrust{
|
|
Mode: test.clusterDeviceMode,
|
|
})
|
|
})
|
|
|
|
// Test the unary, non-MFA endpoint.
|
|
if !test.skipGenerateUserCertsRPC {
|
|
t.Run("GenerateUserCerts", func(t *testing.T) {
|
|
_, err := test.client.GenerateUserCerts(ctx, test.req)
|
|
test.assertErr(t, err)
|
|
})
|
|
}
|
|
|
|
// Test the streaming, MFA-aware endpoint.
|
|
if !test.skipSingleUseCertsRPC {
|
|
t.Run("GenerateUserSingleUseCerts", func(t *testing.T) {
|
|
_, err := generateCertsMFA(t, test.client, test.req)
|
|
test.assertErr(t, err)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateUserSingleUseCert(t *testing.T) {
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
clock := srv.Clock()
|
|
userCertTTL := 12 * time.Hour
|
|
userCertExpires := clock.Now().Add(userCertTTL)
|
|
|
|
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
const webOrigin = "https://localhost" // matches RPID above
|
|
require.NoError(t, err)
|
|
err = srv.Auth().SetAuthPreference(ctx, authPref)
|
|
require.NoError(t, err)
|
|
|
|
// Register an SSH node.
|
|
node := &types.ServerV2{
|
|
Kind: types.KindNode,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Name: "node-a",
|
|
},
|
|
Spec: types.ServerSpecV2{
|
|
Hostname: "node-a",
|
|
},
|
|
}
|
|
_, err = srv.Auth().UpsertNode(ctx, node)
|
|
require.NoError(t, err)
|
|
|
|
kube, err := types.NewKubernetesClusterV3(types.Metadata{
|
|
Name: "kube-a",
|
|
}, types.KubernetesClusterSpecV3{})
|
|
|
|
require.NoError(t, err)
|
|
kubeServer, err := types.NewKubernetesServerV3FromCluster(kube, "kube-a", "kube-a")
|
|
require.NoError(t, err)
|
|
_, err = srv.Auth().UpsertKubernetesServer(ctx, kubeServer)
|
|
require.NoError(t, err)
|
|
// Register a database.
|
|
db, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: "db-a",
|
|
}, types.DatabaseServerSpecV3{
|
|
Protocol: "postgres",
|
|
URI: "localhost",
|
|
Hostname: "localhost",
|
|
HostID: "localhost",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = srv.Auth().UpsertDatabaseServer(ctx, db)
|
|
require.NoError(t, err)
|
|
|
|
// Create a fake user.
|
|
user, role, err := CreateUserAndRole(srv.Auth(), "mfa-user", []string{"role"}, nil)
|
|
require.NoError(t, err)
|
|
// Make sure MFA is required for this user.
|
|
roleOpt := role.GetOptions()
|
|
roleOpt.RequireMFAType = types.RequireMFAType_SESSION
|
|
role.SetOptions(roleOpt)
|
|
err = srv.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
testUser := TestUser(user.GetName())
|
|
testUser.TTL = userCertTTL
|
|
cl, err := srv.NewClient(testUser)
|
|
require.NoError(t, err)
|
|
|
|
// Register MFA devices for the fake user.
|
|
registered := addOneOfEachMFADevice(t, cl, clock, webOrigin)
|
|
|
|
// Fetch MFA device IDs.
|
|
devs, err := srv.Auth().Services.GetMFADevices(ctx, user.GetName(), false)
|
|
require.NoError(t, err)
|
|
var webDevID string
|
|
for _, dev := range devs {
|
|
if dev.GetWebauthn() != nil {
|
|
webDevID = dev.Id
|
|
break
|
|
}
|
|
}
|
|
|
|
_, pub, err := testauthority.New().GenerateKeyPair()
|
|
require.NoError(t, err)
|
|
|
|
// Used for device trust tests.
|
|
wantDeviceExtensions := tlsca.DeviceExtensions{
|
|
DeviceID: "device-id1",
|
|
AssetTag: "device-assettag1",
|
|
CredentialID: "device-credentialid1",
|
|
}
|
|
|
|
tests := []struct {
|
|
desc string
|
|
newClient func() (*Client, error) // optional, makes a new client for the test.
|
|
opts generateUserSingleUseCertTestOpts
|
|
}{
|
|
{
|
|
desc: "ssh using webauthn",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
// This expiry is longer than allowed, should be
|
|
// automatically adjusted.
|
|
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_SSH,
|
|
NodeName: "node-a",
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
crt := c.GetSSH()
|
|
require.NotEmpty(t, crt)
|
|
|
|
cert, err := sshutils.ParseCertificate(crt)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, webDevID, cert.Extensions[teleport.CertExtensionMFAVerified])
|
|
require.Equal(t, userCertExpires.Format(time.RFC3339), cert.Extensions[teleport.CertExtensionPreviousIdentityExpires])
|
|
require.True(t, net.ParseIP(cert.Extensions[teleport.CertExtensionLoginIP]).IsLoopback())
|
|
require.Equal(t, uint64(clock.Now().Add(teleport.UserSingleUseCertTTL).Unix()), cert.ValidBefore)
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "ssh - adjusted expiry",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
// This expiry is longer than allowed, should be
|
|
// automatically adjusted.
|
|
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_SSH,
|
|
NodeName: "node-a",
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
crt := c.GetSSH()
|
|
require.NotEmpty(t, crt)
|
|
|
|
cert, err := sshutils.ParseCertificate(crt)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, webDevID, cert.Extensions[teleport.CertExtensionMFAVerified])
|
|
require.Equal(t, userCertExpires.Format(time.RFC3339), cert.Extensions[teleport.CertExtensionPreviousIdentityExpires])
|
|
require.True(t, net.ParseIP(cert.Extensions[teleport.CertExtensionLoginIP]).IsLoopback())
|
|
require.Equal(t, uint64(clock.Now().Add(teleport.UserSingleUseCertTTL).Unix()), cert.ValidBefore)
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "k8s",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
// This expiry is longer than allowed, should be
|
|
// automatically adjusted.
|
|
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_Kubernetes,
|
|
KubernetesCluster: "kube-a",
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
crt := c.GetTLS()
|
|
require.NotEmpty(t, crt)
|
|
|
|
cert, err := tlsca.ParseCertificatePEM(crt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, cert.NotAfter, clock.Now().Add(teleport.UserSingleUseCertTTL))
|
|
|
|
identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
require.NoError(t, err)
|
|
require.Equal(t, webDevID, identity.MFAVerified)
|
|
require.Equal(t, userCertExpires, identity.PreviousIdentityExpires)
|
|
require.True(t, net.ParseIP(identity.LoginIP).IsLoopback())
|
|
require.Equal(t, []string{teleport.UsageKubeOnly}, identity.Usage)
|
|
require.Equal(t, "kube-a", identity.KubernetesCluster)
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "db",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
// This expiry is longer than allowed, should be
|
|
// automatically adjusted.
|
|
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_Database,
|
|
RouteToDatabase: proto.RouteToDatabase{
|
|
ServiceName: "db-a",
|
|
},
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
crt := c.GetTLS()
|
|
require.NotEmpty(t, crt)
|
|
|
|
cert, err := tlsca.ParseCertificatePEM(crt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, clock.Now().Add(teleport.UserSingleUseCertTTL), cert.NotAfter)
|
|
|
|
identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
require.NoError(t, err)
|
|
require.Equal(t, webDevID, identity.MFAVerified)
|
|
require.Equal(t, userCertExpires, identity.PreviousIdentityExpires)
|
|
require.True(t, net.ParseIP(identity.LoginIP).IsLoopback())
|
|
require.Equal(t, []string{teleport.UsageDatabaseOnly}, identity.Usage)
|
|
require.Equal(t, identity.RouteToDatabase.ServiceName, "db-a")
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "db with ttl limit disabled",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
// This expiry should *not* be adjusted to single user cert TTL,
|
|
// since ttl limiting is disabled when requester is a local proxy tunnel.
|
|
// It *should* be adjusted to the user cert ttl though.
|
|
Expires: clock.Now().Add(1000 * time.Hour),
|
|
Usage: proto.UserCertsRequest_Database,
|
|
RouteToDatabase: proto.RouteToDatabase{
|
|
ServiceName: "db-a",
|
|
},
|
|
RequesterName: proto.UserCertsRequest_TSH_DB_LOCAL_PROXY_TUNNEL,
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
crt := c.GetTLS()
|
|
require.NotEmpty(t, crt)
|
|
|
|
cert, err := tlsca.ParseCertificatePEM(crt)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userCertExpires, cert.NotAfter)
|
|
|
|
identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
require.NoError(t, err)
|
|
require.Equal(t, webDevID, identity.MFAVerified)
|
|
require.Equal(t, userCertExpires, identity.PreviousIdentityExpires)
|
|
require.True(t, net.ParseIP(identity.LoginIP).IsLoopback())
|
|
require.Equal(t, []string{teleport.UsageDatabaseOnly}, identity.Usage)
|
|
require.Equal(t, identity.RouteToDatabase.ServiceName, "db-a")
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "fail - wrong usage",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
Expires: clock.Now().Add(teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_All,
|
|
NodeName: "node-a",
|
|
},
|
|
checkInitErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "fail - mfa challenge fail",
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
Expires: clock.Now().Add(teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_SSH,
|
|
NodeName: "node-a",
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: func(t *testing.T, req *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse {
|
|
// Return no challenge response.
|
|
return &proto.MFAAuthenticateResponse{}
|
|
},
|
|
checkAuthErr: require.Error,
|
|
},
|
|
},
|
|
{
|
|
desc: "device extensions copied SSH cert",
|
|
newClient: func() (*Client, error) {
|
|
u := TestUser(user.GetName())
|
|
u.TTL = 1 * time.Hour
|
|
|
|
// Add device extensions to the fake user's identity.
|
|
localUser := u.I.(authz.LocalUser)
|
|
localUser.Identity.DeviceExtensions = wantDeviceExtensions
|
|
u.I = localUser
|
|
|
|
return srv.NewClient(u)
|
|
},
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
// Same as SSH options. Nothing special here.
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
Expires: clock.Now().Add(teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_SSH,
|
|
NodeName: "node-a",
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
// SSH certificate.
|
|
sshRaw := c.GetSSH()
|
|
require.NotEmpty(t, sshRaw, "Got empty single-use SSH certificate")
|
|
|
|
sshCert, err := sshutils.ParseCertificate(sshRaw)
|
|
require.NoError(t, err, "ParseCertificate failed")
|
|
gotSSH := tlsca.DeviceExtensions{
|
|
DeviceID: sshCert.Extensions[teleport.CertExtensionDeviceID],
|
|
AssetTag: sshCert.Extensions[teleport.CertExtensionDeviceAssetTag],
|
|
CredentialID: sshCert.Extensions[teleport.CertExtensionDeviceCredentialID],
|
|
}
|
|
if diff := cmp.Diff(wantDeviceExtensions, gotSSH, protocmp.Transform()); diff != "" {
|
|
t.Errorf("SSH DeviceExtensions mismatch (-want +got)\n%s", diff)
|
|
}
|
|
},
|
|
},
|
|
},
|
|
{
|
|
desc: "device extensions copied TLS cert",
|
|
newClient: func() (*Client, error) {
|
|
u := TestUser(user.GetName())
|
|
u.TTL = 1 * time.Hour
|
|
|
|
// Add device extensions to the fake user's identity.
|
|
localUser := u.I.(authz.LocalUser)
|
|
localUser.Identity.DeviceExtensions = wantDeviceExtensions
|
|
u.I = localUser
|
|
|
|
return srv.NewClient(u)
|
|
},
|
|
opts: generateUserSingleUseCertTestOpts{
|
|
// Same as Database options. Nothing special here.
|
|
initReq: &proto.UserCertsRequest{
|
|
PublicKey: pub,
|
|
Username: user.GetName(),
|
|
Expires: clock.Now().Add(teleport.UserSingleUseCertTTL),
|
|
Usage: proto.UserCertsRequest_Database,
|
|
RouteToDatabase: proto.RouteToDatabase{
|
|
ServiceName: "db-a",
|
|
},
|
|
},
|
|
checkInitErr: require.NoError,
|
|
authHandler: registered.webAuthHandler,
|
|
checkAuthErr: require.NoError,
|
|
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
|
|
// TLS certificate.
|
|
tlsRaw := c.GetTLS()
|
|
require.NotEmpty(t, tlsRaw, "Got empty single-use TLS certificate")
|
|
|
|
block, _ := pem.Decode(tlsRaw)
|
|
require.NotNil(t, block, "Decode failed (TLS PEM)")
|
|
tlsCert, err := x509.ParseCertificate(block.Bytes)
|
|
require.NoError(t, err, "ParseCertificate failed")
|
|
|
|
singleUseIdentity, err := tlsca.FromSubject(tlsCert.Subject, tlsCert.NotAfter)
|
|
require.NoError(t, err, "FromSubject failed")
|
|
gotTLS := singleUseIdentity.DeviceExtensions
|
|
if diff := cmp.Diff(wantDeviceExtensions, gotTLS, protocmp.Transform()); diff != "" {
|
|
t.Errorf("TLS DeviceExtensions mismatch (-want +got)\n%s", diff)
|
|
}
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
testClient := cl
|
|
if tt.newClient != nil {
|
|
var err error
|
|
testClient, err = tt.newClient()
|
|
require.NoError(t, err, "newClient failed")
|
|
}
|
|
|
|
testGenerateUserSingleUseCert(ctx, t, testClient, tt.opts)
|
|
})
|
|
}
|
|
}
|
|
|
|
type generateUserSingleUseCertTestOpts struct {
|
|
initReq *proto.UserCertsRequest
|
|
checkInitErr require.ErrorAssertionFunc
|
|
authHandler func(*testing.T, *proto.MFAAuthenticateChallenge) *proto.MFAAuthenticateResponse
|
|
checkAuthErr require.ErrorAssertionFunc
|
|
validateCert func(*testing.T, *proto.SingleUseUserCert)
|
|
}
|
|
|
|
func testGenerateUserSingleUseCert(ctx context.Context, t *testing.T, cl *Client, opts generateUserSingleUseCertTestOpts) {
|
|
stream, err := cl.GenerateUserSingleUseCerts(ctx)
|
|
require.NoError(t, err)
|
|
err = stream.Send(&proto.UserSingleUseCertsRequest{Request: &proto.UserSingleUseCertsRequest_Init{Init: opts.initReq}})
|
|
require.NoError(t, err)
|
|
|
|
authChallenge, err := stream.Recv()
|
|
opts.checkInitErr(t, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
authResp := opts.authHandler(t, authChallenge.GetMFAChallenge())
|
|
err = stream.Send(&proto.UserSingleUseCertsRequest{Request: &proto.UserSingleUseCertsRequest_MFAResponse{MFAResponse: authResp}})
|
|
require.NoError(t, err)
|
|
|
|
certs, err := stream.Recv()
|
|
opts.checkAuthErr(t, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
opts.validateCert(t, certs.GetCert())
|
|
|
|
require.NoError(t, stream.CloseSend())
|
|
}
|
|
|
|
var requireMFATypes = []types.RequireMFAType{
|
|
types.RequireMFAType_OFF,
|
|
types.RequireMFAType_SESSION,
|
|
types.RequireMFAType_SESSION_AND_HARDWARE_KEY,
|
|
types.RequireMFAType_HARDWARE_KEY_TOUCH,
|
|
}
|
|
|
|
func TestIsMFARequired(t *testing.T) {
|
|
modules.SetTestModules(t, &modules.TestModules{TestBuildType: modules.BuildEnterprise})
|
|
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
// Register an SSH node.
|
|
node := &types.ServerV2{
|
|
Kind: types.KindNode,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Name: "node-a",
|
|
},
|
|
Spec: types.ServerSpecV2{
|
|
Hostname: "node-a",
|
|
},
|
|
}
|
|
_, err := srv.Auth().UpsertNode(ctx, node)
|
|
require.NoError(t, err)
|
|
|
|
// Create a fake user.
|
|
user, role, err := CreateUserAndRole(srv.Auth(), "no-mfa-user", []string{"no-mfa-user"}, nil)
|
|
require.NoError(t, err)
|
|
|
|
for _, authPrefRequireMFAType := range requireMFATypes {
|
|
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
RequireMFAType: authPrefRequireMFAType,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "teleport",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
err = srv.Auth().SetAuthPreference(ctx, authPref)
|
|
require.NoError(t, err)
|
|
|
|
for _, roleRequireMFAType := range requireMFATypes {
|
|
// If role or auth pref have "hardware_key_touch", expect not required.
|
|
expectRequired := !(roleRequireMFAType == types.RequireMFAType_HARDWARE_KEY_TOUCH || authPrefRequireMFAType == types.RequireMFAType_HARDWARE_KEY_TOUCH)
|
|
// Otherwise, if auth pref or role require session MFA, expect required.
|
|
expectRequired = expectRequired && (roleRequireMFAType.IsSessionMFARequired() || authPrefRequireMFAType.IsSessionMFARequired())
|
|
|
|
t.Run(fmt.Sprintf("authPref=%v/role=%v/expect=%v", authPrefRequireMFAType, roleRequireMFAType, expectRequired), func(t *testing.T) {
|
|
roleOpt := role.GetOptions()
|
|
roleOpt.RequireMFAType = roleRequireMFAType
|
|
role.SetOptions(roleOpt)
|
|
err = srv.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
cl, err := srv.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
resp, err := cl.IsMFARequired(ctx, &proto.IsMFARequiredRequest{
|
|
Target: &proto.IsMFARequiredRequest_Node{Node: &proto.NodeLogin{
|
|
Login: user.GetName(),
|
|
Node: "node-a",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectRequired, resp.Required)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsMFARequiredUnauthorized(t *testing.T) {
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
// Enable MFA support.
|
|
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "teleport",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
err = srv.Auth().SetAuthPreference(ctx, authPref)
|
|
require.NoError(t, err)
|
|
|
|
// Register an SSH node.
|
|
node1 := &types.ServerV2{
|
|
Kind: types.KindNode,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Name: "node1",
|
|
Namespace: apidefaults.Namespace,
|
|
Labels: map[string]string{"a": "b"},
|
|
},
|
|
Spec: types.ServerSpecV2{
|
|
Hostname: "node1",
|
|
Addr: "localhost:3022",
|
|
},
|
|
}
|
|
_, err = srv.Auth().UpsertNode(ctx, node1)
|
|
require.NoError(t, err)
|
|
|
|
// Register another SSH node with a duplicate hostname.
|
|
node2 := &types.ServerV2{
|
|
Kind: types.KindNode,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Name: "node2",
|
|
Namespace: apidefaults.Namespace,
|
|
Labels: map[string]string{"a": "c"},
|
|
},
|
|
Spec: types.ServerSpecV2{
|
|
Hostname: "node1",
|
|
Addr: "localhost:3022",
|
|
},
|
|
}
|
|
_, err = srv.Auth().UpsertNode(ctx, node2)
|
|
require.NoError(t, err)
|
|
|
|
user, role, err := CreateUserAndRole(srv.Auth(), "alice", []string{"alice"}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Require MFA.
|
|
roleOpt := role.GetOptions()
|
|
roleOpt.RequireMFAType = types.RequireMFAType_SESSION
|
|
role.SetOptions(roleOpt)
|
|
role.SetNodeLabels(types.Allow, map[string]apiutils.Strings{"a": []string{"c"}})
|
|
err = srv.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
cl, err := srv.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
// Call the endpoint for an authorized login. The user is only authorized
|
|
// for the 2nd node, but should still be asked for MFA.
|
|
resp, err := cl.IsMFARequired(ctx, &proto.IsMFARequiredRequest{
|
|
Target: &proto.IsMFARequiredRequest_Node{Node: &proto.NodeLogin{
|
|
Login: "alice",
|
|
Node: "node1",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Required)
|
|
|
|
// Call the endpoint for an unauthorized login.
|
|
resp, err = cl.IsMFARequired(ctx, &proto.IsMFARequiredRequest{
|
|
Target: &proto.IsMFARequiredRequest_Node{Node: &proto.NodeLogin{
|
|
Login: "bob",
|
|
Node: "node1",
|
|
}},
|
|
})
|
|
|
|
// When unauthorized, expect a silent `false`.
|
|
require.NoError(t, err)
|
|
require.False(t, resp.Required)
|
|
}
|
|
|
|
// TestRoleVersions tests that downgraded V6 roles are returned to older
|
|
// clients, and V6 roles are returned to newer clients.
|
|
func TestRoleVersions(t *testing.T) {
|
|
srv := newTestTLSServer(t)
|
|
|
|
role := &types.RoleV6{
|
|
Kind: types.KindRole,
|
|
Version: types.V6,
|
|
Metadata: types.Metadata{
|
|
Name: "test_role",
|
|
},
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindRole, services.RO()),
|
|
types.NewRule(types.KindEvent, services.RW()),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
user, err := CreateUser(srv.Auth(), "test_user", role)
|
|
require.NoError(t, err)
|
|
|
|
client, err := srv.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
testCases := []struct {
|
|
desc string
|
|
clientVersion string
|
|
disableMetadata bool
|
|
expectedRoleVersion string
|
|
assertErr require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
desc: "old",
|
|
clientVersion: "11.1.1",
|
|
expectedRoleVersion: types.V5,
|
|
assertErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "old v9",
|
|
clientVersion: "9.0.0",
|
|
expectedRoleVersion: types.V5,
|
|
assertErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "alpha",
|
|
clientVersion: "11.2.4-alpha.0",
|
|
expectedRoleVersion: types.V5,
|
|
assertErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "greater than 12",
|
|
clientVersion: "12.0.0-beta",
|
|
expectedRoleVersion: types.V6,
|
|
assertErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "12",
|
|
clientVersion: "12.0.0",
|
|
expectedRoleVersion: types.V6,
|
|
assertErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "empty version",
|
|
clientVersion: "",
|
|
assertErr: require.Error,
|
|
},
|
|
{
|
|
desc: "invalid version",
|
|
clientVersion: "foo",
|
|
assertErr: require.Error,
|
|
},
|
|
{
|
|
desc: "no version metadata",
|
|
disableMetadata: true,
|
|
expectedRoleVersion: types.V5,
|
|
assertErr: require.NoError,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
// setup client metadata
|
|
ctx := context.Background()
|
|
if tc.disableMetadata {
|
|
ctx = context.WithValue(ctx, metadata.DisableInterceptors{}, struct{}{})
|
|
} else {
|
|
ctx = metadata.AddMetadataToContext(ctx, map[string]string{
|
|
metadata.VersionKey: tc.clientVersion,
|
|
})
|
|
}
|
|
|
|
// test GetRole
|
|
gotRole, err := client.GetRole(ctx, role.GetName())
|
|
tc.assertErr(t, err)
|
|
if err == nil {
|
|
require.Equal(t, tc.expectedRoleVersion, gotRole.GetVersion())
|
|
}
|
|
|
|
// test GetRoles
|
|
gotRoles, err := client.GetRoles(ctx)
|
|
tc.assertErr(t, err)
|
|
if err == nil {
|
|
foundTestRole := false
|
|
for _, gotRole := range gotRoles {
|
|
if gotRole.GetName() == role.GetName() {
|
|
require.Equal(t, tc.expectedRoleVersion, gotRole.GetVersion())
|
|
foundTestRole = true
|
|
}
|
|
}
|
|
require.True(t, foundTestRole)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// testOriginDynamicStored tests setting a ResourceWithOrigin via the server
|
|
// API always results in the resource being stored with OriginDynamic.
|
|
func testOriginDynamicStored(t *testing.T, setWithOrigin func(*Client, string) error, getStored func(*Server) (types.ResourceWithOrigin, error)) {
|
|
srv := newTestTLSServer(t)
|
|
|
|
// Create a fake user.
|
|
user, _, err := CreateUserAndRole(srv.Auth(), "configurer", []string{}, nil)
|
|
require.NoError(t, err)
|
|
cl, err := srv.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
for _, origin := range types.OriginValues {
|
|
t.Run(fmt.Sprintf("setting with origin %q", origin), func(t *testing.T) {
|
|
err := setWithOrigin(cl, origin)
|
|
require.NoError(t, err)
|
|
|
|
stored, err := getStored(srv.Auth())
|
|
require.NoError(t, err)
|
|
require.Equal(t, stored.Origin(), types.OriginDynamic)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthPreferenceOriginDynamic(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
setWithOrigin := func(cl *Client, origin string) error {
|
|
authPref := types.DefaultAuthPreference()
|
|
authPref.SetOrigin(origin)
|
|
return cl.SetAuthPreference(ctx, authPref)
|
|
}
|
|
|
|
getStored := func(asrv *Server) (types.ResourceWithOrigin, error) {
|
|
return asrv.GetAuthPreference(ctx)
|
|
}
|
|
|
|
testOriginDynamicStored(t, setWithOrigin, getStored)
|
|
}
|
|
|
|
func TestClusterNetworkingConfigOriginDynamic(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
setWithOrigin := func(cl *Client, origin string) error {
|
|
netConfig := types.DefaultClusterNetworkingConfig()
|
|
netConfig.SetOrigin(origin)
|
|
return cl.SetClusterNetworkingConfig(ctx, netConfig)
|
|
}
|
|
|
|
getStored := func(asrv *Server) (types.ResourceWithOrigin, error) {
|
|
return asrv.GetClusterNetworkingConfig(ctx)
|
|
}
|
|
|
|
testOriginDynamicStored(t, setWithOrigin, getStored)
|
|
}
|
|
|
|
func TestSessionRecordingConfigOriginDynamic(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
setWithOrigin := func(cl *Client, origin string) error {
|
|
recConfig := types.DefaultSessionRecordingConfig()
|
|
recConfig.SetOrigin(origin)
|
|
return cl.SetSessionRecordingConfig(ctx, recConfig)
|
|
}
|
|
|
|
getStored := func(asrv *Server) (types.ResourceWithOrigin, error) {
|
|
return asrv.GetSessionRecordingConfig(ctx)
|
|
}
|
|
|
|
testOriginDynamicStored(t, setWithOrigin, getStored)
|
|
}
|
|
|
|
func TestGenerateHostCerts(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
priv, pub, err := testauthority.New().GenerateKeyPair()
|
|
require.NoError(t, err)
|
|
|
|
pubTLS, err := PrivateKeyToPublicKeyTLS(priv)
|
|
require.NoError(t, err)
|
|
|
|
certs, err := clt.GenerateHostCerts(ctx, &proto.HostCertsRequest{
|
|
HostID: "Admin",
|
|
Role: types.RoleAdmin,
|
|
NodeName: "foo",
|
|
// Ensure that 0.0.0.0 gets replaced with the RemoteAddr of the client
|
|
AdditionalPrincipals: []string{"0.0.0.0"},
|
|
PublicSSHKey: pub,
|
|
PublicTLSKey: pubTLS,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, certs)
|
|
}
|
|
|
|
// TestInstanceCertAndControlStream attempts to generate an instance cert via the
|
|
// assertion API and use it to handle an inventory ping via the control stream.
|
|
func TestInstanceCertAndControlStream(t *testing.T) {
|
|
const assertionID = "test-assertion"
|
|
const serverID = "test-server"
|
|
t.Parallel()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
srv := newTestTLSServer(t)
|
|
|
|
roles := []types.SystemRole{
|
|
types.RoleNode,
|
|
types.RoleAuth,
|
|
types.RoleProxy,
|
|
}
|
|
|
|
clt, err := srv.NewClient(TestServerID(types.RoleNode, serverID))
|
|
require.NoError(t, err)
|
|
defer clt.Close()
|
|
|
|
priv, pub, err := testauthority.New().GenerateKeyPair()
|
|
require.NoError(t, err)
|
|
|
|
pubTLS, err := PrivateKeyToPublicKeyTLS(priv)
|
|
require.NoError(t, err)
|
|
|
|
req := proto.HostCertsRequest{
|
|
HostID: serverID,
|
|
Role: types.RoleInstance,
|
|
PublicSSHKey: pub,
|
|
PublicTLSKey: pubTLS,
|
|
SystemRoles: roles,
|
|
// assertion ID is omitted initially to test
|
|
// the failure case
|
|
}
|
|
|
|
// request should fail since clt only holds RoleNode
|
|
_, err = clt.GenerateHostCerts(ctx, &req)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
|
|
// perform assertions
|
|
for _, role := range roles {
|
|
func() {
|
|
clt, err := srv.NewClient(TestServerID(role, serverID))
|
|
require.NoError(t, err)
|
|
defer clt.Close()
|
|
|
|
err = clt.UnstableAssertSystemRole(ctx, proto.UnstableSystemRoleAssertion{
|
|
ServerID: serverID,
|
|
AssertionID: assertionID,
|
|
SystemRole: role,
|
|
})
|
|
require.NoError(t, err)
|
|
}()
|
|
}
|
|
|
|
// set assertion ID
|
|
req.UnstableSystemRoleAssertionID = assertionID
|
|
|
|
// assertion should allow us to generate certs
|
|
certs, err := clt.GenerateHostCerts(ctx, &req)
|
|
require.NoError(t, err)
|
|
|
|
// make an instance client
|
|
instanceCert, err := tls.X509KeyPair(certs.TLS, priv)
|
|
require.NoError(t, err)
|
|
instanceClt := srv.NewClientWithCert(instanceCert)
|
|
|
|
// instance cert can self-renew without assertions
|
|
req.UnstableSystemRoleAssertionID = ""
|
|
_, err = instanceClt.GenerateHostCerts(ctx, &req)
|
|
require.NoError(t, err)
|
|
|
|
stream, err := instanceClt.InventoryControlStream(ctx)
|
|
require.NoError(t, err)
|
|
defer stream.Close()
|
|
|
|
err = stream.Send(ctx, proto.UpstreamInventoryHello{
|
|
ServerID: serverID,
|
|
Version: teleport.Version,
|
|
Services: roles,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case msg := <-stream.Recv():
|
|
_, ok := msg.(proto.DownstreamInventoryHello)
|
|
require.True(t, ok)
|
|
case <-time.After(time.Second * 5):
|
|
t.Fatalf("timeout waiting for downstream hello")
|
|
}
|
|
|
|
// fire off a ping in the background
|
|
pingErr := make(chan error, 1)
|
|
go func() {
|
|
defer close(pingErr)
|
|
// get an admin client so that we can test pings
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
if err != nil {
|
|
pingErr <- err
|
|
return
|
|
}
|
|
defer clt.Close()
|
|
|
|
_, err = clt.PingInventory(ctx, proto.InventoryPingRequest{
|
|
ServerID: serverID,
|
|
})
|
|
pingErr <- err
|
|
}()
|
|
|
|
// wait for the ping
|
|
select {
|
|
case msg := <-stream.Recv():
|
|
ping, ok := msg.(proto.DownstreamInventoryPing)
|
|
require.True(t, ok)
|
|
err = stream.Send(ctx, proto.UpstreamInventoryPong{
|
|
ID: ping.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
case <-time.After(time.Second * 5):
|
|
t.Fatalf("timeout waiting for downstream ping")
|
|
}
|
|
|
|
// ensure that bg ping routine was successful
|
|
require.NoError(t, <-pingErr)
|
|
}
|
|
|
|
func TestNodesCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
// node1 and node2 will be added to default namespace
|
|
node1, err := types.NewServerWithLabels("node1", types.KindNode, types.ServerSpecV2{}, nil)
|
|
require.NoError(t, err)
|
|
node2, err := types.NewServerWithLabels("node2", types.KindNode, types.ServerSpecV2{}, nil)
|
|
require.NoError(t, err)
|
|
|
|
t.Run("CreateNode", func(t *testing.T) {
|
|
// Initially expect no nodes to be returned.
|
|
nodes, err := clt.GetNodes(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Empty(t, nodes)
|
|
|
|
// Create nodes.
|
|
_, err = clt.UpsertNode(ctx, node1)
|
|
require.NoError(t, err)
|
|
|
|
_, err = clt.UpsertNode(ctx, node2)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
// Run NodeGetters in nested subtests to allow parallelization.
|
|
t.Run("NodeGetters", func(t *testing.T) {
|
|
t.Run("GetNodes", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Get all nodes
|
|
nodes, err := clt.GetNodes(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Len(t, nodes, 2)
|
|
require.Empty(t, cmp.Diff([]types.Server{node1, node2}, nodes,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID")))
|
|
|
|
// GetNodes should not fail if namespace is empty
|
|
_, err = clt.GetNodes(ctx, "")
|
|
require.NoError(t, err)
|
|
})
|
|
t.Run("GetNode", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Get Node
|
|
node, err := clt.GetNode(ctx, apidefaults.Namespace, "node1")
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(node1, node,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID")))
|
|
|
|
// GetNode should fail if node name isn't provided
|
|
_, err = clt.GetNode(ctx, apidefaults.Namespace, "")
|
|
require.IsType(t, &trace.BadParameterError{}, err.(*trace.TraceErr).OrigError())
|
|
|
|
// GetNode should fail if namespace isn't provided
|
|
_, err = clt.GetNode(ctx, "", "node1")
|
|
require.IsType(t, &trace.BadParameterError{}, err.(*trace.TraceErr).OrigError())
|
|
})
|
|
})
|
|
|
|
t.Run("DeleteNode", func(t *testing.T) {
|
|
// Make sure can't delete with empty namespace or name.
|
|
err = clt.DeleteNode(ctx, apidefaults.Namespace, "")
|
|
require.Error(t, err)
|
|
require.IsType(t, trace.BadParameter(""), err)
|
|
|
|
err = clt.DeleteNode(ctx, "", node1.GetName())
|
|
require.Error(t, err)
|
|
require.IsType(t, trace.BadParameter(""), err)
|
|
|
|
// Delete node.
|
|
err = clt.DeleteNode(ctx, apidefaults.Namespace, node1.GetName())
|
|
require.NoError(t, err)
|
|
|
|
// Expect node not found
|
|
_, err := clt.GetNode(ctx, apidefaults.Namespace, "node1")
|
|
require.IsType(t, trace.NotFound(""), err)
|
|
})
|
|
|
|
t.Run("DeleteAllNodes", func(t *testing.T) {
|
|
// Make sure can't delete with empty namespace.
|
|
err = clt.DeleteAllNodes(ctx, "")
|
|
require.Error(t, err)
|
|
require.IsType(t, trace.BadParameter(""), err)
|
|
|
|
// Delete nodes
|
|
err = clt.DeleteAllNodes(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
|
|
// Now expect no nodes to be returned.
|
|
nodes, err := clt.GetNodes(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Empty(t, nodes)
|
|
})
|
|
}
|
|
|
|
func TestLocksCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
now := srv.Clock().Now()
|
|
lock1, err := types.NewLock("lock1", types.LockSpecV2{
|
|
Target: types.LockTarget{
|
|
User: "user-A",
|
|
},
|
|
Expires: &now,
|
|
})
|
|
require.NoError(t, err)
|
|
lock1.SetCreatedBy(string(types.RoleAdmin))
|
|
lock1.SetCreatedAt(now)
|
|
|
|
lock2, err := types.NewLock("lock2", types.LockSpecV2{
|
|
Target: types.LockTarget{
|
|
Node: "node",
|
|
},
|
|
Message: "node compromised",
|
|
})
|
|
require.NoError(t, err)
|
|
lock2.SetCreatedBy(string(types.RoleAdmin))
|
|
lock2.SetCreatedAt(now)
|
|
|
|
t.Run("CreateLock", func(t *testing.T) {
|
|
// Initially expect no locks to be returned.
|
|
locks, err := clt.GetLocks(ctx, false)
|
|
require.NoError(t, err)
|
|
require.Empty(t, locks)
|
|
|
|
// Create locks.
|
|
err = clt.UpsertLock(ctx, lock1)
|
|
require.NoError(t, err)
|
|
|
|
err = clt.UpsertLock(ctx, lock2)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
// Run LockGetters in nested subtests to allow parallelization.
|
|
t.Run("LockGetters", func(t *testing.T) {
|
|
t.Run("GetLocks", func(t *testing.T) {
|
|
t.Parallel()
|
|
locks, err := clt.GetLocks(ctx, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, locks, 2)
|
|
require.Empty(t, cmp.Diff([]types.Lock{lock1, lock2}, locks,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID")))
|
|
})
|
|
t.Run("GetLocks with targets", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Match both locks with the targets.
|
|
locks, err := clt.GetLocks(ctx, false, lock1.Target(), lock2.Target())
|
|
require.NoError(t, err)
|
|
require.Len(t, locks, 2)
|
|
require.Empty(t, cmp.Diff([]types.Lock{lock1, lock2}, locks,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID")))
|
|
|
|
// Match only one of the locks.
|
|
roleTarget := types.LockTarget{Role: "role-A"}
|
|
locks, err = clt.GetLocks(ctx, false, lock1.Target(), roleTarget)
|
|
require.NoError(t, err)
|
|
require.Len(t, locks, 1)
|
|
require.Empty(t, cmp.Diff([]types.Lock{lock1}, locks,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID")))
|
|
|
|
// Match none of the locks.
|
|
locks, err = clt.GetLocks(ctx, false, roleTarget)
|
|
require.NoError(t, err)
|
|
require.Empty(t, locks)
|
|
})
|
|
t.Run("GetLock", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Get one of the locks.
|
|
lock, err := clt.GetLock(ctx, lock1.GetName())
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(lock1, lock,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID")))
|
|
|
|
// Attempt to get a nonexistent lock.
|
|
_, err = clt.GetLock(ctx, "lock3")
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsNotFound(err))
|
|
})
|
|
})
|
|
|
|
t.Run("UpsertLock", func(t *testing.T) {
|
|
// Get one of the locks.
|
|
lock, err := clt.GetLock(ctx, lock1.GetName())
|
|
require.NoError(t, err)
|
|
require.Empty(t, lock.Message())
|
|
|
|
msg := "cluster maintenance"
|
|
lock1.SetMessage(msg)
|
|
err = clt.UpsertLock(ctx, lock1)
|
|
require.NoError(t, err)
|
|
|
|
lock, err = clt.GetLock(ctx, lock1.GetName())
|
|
require.NoError(t, err)
|
|
require.Equal(t, msg, lock.Message())
|
|
})
|
|
|
|
t.Run("DeleteLock", func(t *testing.T) {
|
|
// Delete lock.
|
|
err = clt.DeleteLock(ctx, lock1.GetName())
|
|
require.NoError(t, err)
|
|
|
|
// Expect lock not found.
|
|
_, err := clt.GetLock(ctx, lock1.GetName())
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsNotFound(err))
|
|
})
|
|
}
|
|
|
|
// TestApplicationServersCRUD tests application server operations.
|
|
func TestApplicationServersCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
// Create a couple app servers.
|
|
app1, err := types.NewAppV3(types.Metadata{Name: "app-1"},
|
|
types.AppSpecV3{URI: "localhost"})
|
|
require.NoError(t, err)
|
|
server1, err := types.NewAppServerV3FromApp(app1, "server-1", "server-1")
|
|
require.NoError(t, err)
|
|
app2, err := types.NewAppV3(types.Metadata{Name: "app-2"},
|
|
types.AppSpecV3{URI: "localhost"})
|
|
require.NoError(t, err)
|
|
server2, err := types.NewAppServerV3FromApp(app2, "server-2", "server-2")
|
|
require.NoError(t, err)
|
|
app3, err := types.NewAppV3(types.Metadata{Name: "app-3"},
|
|
types.AppSpecV3{URI: "localhost"})
|
|
require.NoError(t, err)
|
|
server3, err := types.NewAppServerV3FromApp(app3, "server-3", "server-3")
|
|
require.NoError(t, err)
|
|
|
|
// Initially we expect no app servers.
|
|
out, err := clt.GetApplicationServers(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, len(out))
|
|
|
|
// Register all app servers.
|
|
_, err = clt.UpsertApplicationServer(ctx, server1)
|
|
require.NoError(t, err)
|
|
_, err = clt.UpsertApplicationServer(ctx, server2)
|
|
require.NoError(t, err)
|
|
_, err = clt.UpsertApplicationServer(ctx, server3)
|
|
require.NoError(t, err)
|
|
|
|
// Fetch all app servers.
|
|
out, err = clt.GetApplicationServers(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.AppServer{server1, server2, server3}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Update an app server.
|
|
server1.Metadata.Description = "description"
|
|
_, err = clt.UpsertApplicationServer(ctx, server1)
|
|
require.NoError(t, err)
|
|
out, err = clt.GetApplicationServers(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.AppServer{server1, server2, server3}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Delete an app server.
|
|
err = clt.DeleteApplicationServer(ctx, server1.GetNamespace(), server1.GetHostID(), server1.GetName())
|
|
require.NoError(t, err)
|
|
out, err = clt.GetApplicationServers(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.AppServer{server2, server3}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Delete all app servers.
|
|
err = clt.DeleteAllApplicationServers(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
out, err = clt.GetApplicationServers(ctx, apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, len(out))
|
|
}
|
|
|
|
// TestAppsCRUD tests application resource operations.
|
|
func TestAppsCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
// Create a couple apps.
|
|
app1, err := types.NewAppV3(types.Metadata{
|
|
Name: "app1",
|
|
Labels: map[string]string{types.OriginLabel: types.OriginDynamic},
|
|
}, types.AppSpecV3{
|
|
URI: "localhost1",
|
|
})
|
|
require.NoError(t, err)
|
|
app2, err := types.NewAppV3(types.Metadata{
|
|
Name: "app2",
|
|
Labels: map[string]string{types.OriginLabel: types.OriginDynamic},
|
|
}, types.AppSpecV3{
|
|
URI: "localhost2",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Initially we expect no apps.
|
|
out, err := clt.GetApps(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, len(out))
|
|
|
|
// Create both apps.
|
|
err = clt.CreateApp(ctx, app1)
|
|
require.NoError(t, err)
|
|
err = clt.CreateApp(ctx, app2)
|
|
require.NoError(t, err)
|
|
|
|
// Fetch all apps.
|
|
out, err = clt.GetApps(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.Application{app1, app2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Fetch a specific app.
|
|
app, err := clt.GetApp(ctx, app2.GetName())
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(app2, app,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Try to fetch an app that doesn't exist.
|
|
_, err = clt.GetApp(ctx, "doesnotexist")
|
|
require.IsType(t, trace.NotFound(""), err)
|
|
|
|
// Try to create the same app.
|
|
err = clt.CreateApp(ctx, app1)
|
|
require.IsType(t, trace.AlreadyExists(""), err)
|
|
|
|
// Update an app.
|
|
app1.Metadata.Description = "description"
|
|
err = clt.UpdateApp(ctx, app1)
|
|
require.NoError(t, err)
|
|
app, err = clt.GetApp(ctx, app1.GetName())
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(app1, app,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Delete an app.
|
|
err = clt.DeleteApp(ctx, app1.GetName())
|
|
require.NoError(t, err)
|
|
out, err = clt.GetApps(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.Application{app2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Try to delete an app that doesn't exist.
|
|
err = clt.DeleteApp(ctx, "doesnotexist")
|
|
require.IsType(t, trace.NotFound(""), err)
|
|
|
|
// Delete all apps.
|
|
err = clt.DeleteAllApps(ctx)
|
|
require.NoError(t, err)
|
|
out, err = clt.GetApps(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, out, 0)
|
|
}
|
|
|
|
// TestDatabasesCRUD tests database resource operations.
|
|
func TestDatabasesCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
// Create a couple databases.
|
|
db1, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "db1",
|
|
Labels: map[string]string{types.OriginLabel: types.OriginDynamic},
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: defaults.ProtocolPostgres,
|
|
URI: "localhost:5432",
|
|
})
|
|
require.NoError(t, err)
|
|
db2, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "db2",
|
|
Labels: map[string]string{types.OriginLabel: types.OriginDynamic},
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: defaults.ProtocolMySQL,
|
|
URI: "localhost:3306",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Initially we expect no databases.
|
|
out, err := clt.GetDatabases(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, len(out))
|
|
|
|
// Create both databases.
|
|
err = clt.CreateDatabase(ctx, db1)
|
|
require.NoError(t, err)
|
|
err = clt.CreateDatabase(ctx, db2)
|
|
require.NoError(t, err)
|
|
|
|
// Fetch all databases.
|
|
out, err = clt.GetDatabases(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.Database{db1, db2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Fetch a specific database.
|
|
db, err := clt.GetDatabase(ctx, db2.GetName())
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(db2, db,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Try to fetch a database that doesn't exist.
|
|
_, err = clt.GetDatabase(ctx, "doesnotexist")
|
|
require.IsType(t, trace.NotFound(""), err)
|
|
|
|
// Try to create the same database.
|
|
err = clt.CreateDatabase(ctx, db1)
|
|
require.IsType(t, trace.AlreadyExists(""), err)
|
|
|
|
// Update a database.
|
|
db1.Metadata.Description = "description"
|
|
err = clt.UpdateDatabase(ctx, db1)
|
|
require.NoError(t, err)
|
|
db, err = clt.GetDatabase(ctx, db1.GetName())
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(db1, db,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Delete a database.
|
|
err = clt.DeleteDatabase(ctx, db1.GetName())
|
|
require.NoError(t, err)
|
|
out, err = clt.GetDatabases(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.Database{db2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Try to delete a database that doesn't exist.
|
|
err = clt.DeleteDatabase(ctx, "doesnotexist")
|
|
require.IsType(t, trace.NotFound(""), err)
|
|
|
|
// Delete all databases.
|
|
err = clt.DeleteAllDatabases(ctx)
|
|
require.NoError(t, err)
|
|
out, err = clt.GetDatabases(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, out, 0)
|
|
}
|
|
|
|
// TestDatabaseServicesCRUD tests DatabaseService resource operations.
|
|
func TestDatabaseServicesCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
// Create two DatabaseServices.
|
|
db1, err := types.NewDatabaseServiceV1(types.Metadata{
|
|
Name: "db1",
|
|
Labels: map[string]string{types.OriginLabel: types.OriginDynamic},
|
|
}, types.DatabaseServiceSpecV1{
|
|
ResourceMatchers: []*types.DatabaseResourceMatcher{
|
|
{
|
|
Labels: &types.Labels{
|
|
"env": []string{"prod"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
db2, err := types.NewDatabaseServiceV1(types.Metadata{
|
|
Name: "db2",
|
|
Labels: map[string]string{types.OriginLabel: types.OriginDynamic},
|
|
}, types.DatabaseServiceSpecV1{
|
|
ResourceMatchers: []*types.DatabaseResourceMatcher{
|
|
{
|
|
Labels: &types.Labels{
|
|
"env": []string{"stg"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Initially we expect no DatabaseServices.
|
|
listServicesResp, err := clt.ListResources(ctx,
|
|
proto.ListResourcesRequest{
|
|
ResourceType: types.KindDatabaseService,
|
|
Limit: apidefaults.DefaultChunkSize,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
out, err := types.ResourcesWithLabels(listServicesResp.Resources).AsDatabaseServices()
|
|
require.NoError(t, err)
|
|
require.Empty(t, out)
|
|
|
|
// Create both DatabaseServices.
|
|
_, err = clt.UpsertDatabaseService(ctx, db1)
|
|
require.NoError(t, err)
|
|
_, err = clt.UpsertDatabaseService(ctx, db2)
|
|
require.NoError(t, err)
|
|
|
|
// Fetch all DatabaseServices.
|
|
listServicesResp, err = clt.ListResources(ctx,
|
|
proto.ListResourcesRequest{
|
|
ResourceType: types.KindDatabaseService,
|
|
Limit: apidefaults.DefaultChunkSize,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
out, err = types.ResourcesWithLabels(listServicesResp.Resources).AsDatabaseServices()
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.DatabaseService{db1, db2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Update a DatabaseService.
|
|
db1.Spec.ResourceMatchers[0] = &types.DatabaseResourceMatcher{
|
|
Labels: &types.Labels{
|
|
"env": []string{"notprod"},
|
|
},
|
|
}
|
|
|
|
_, err = clt.UpsertDatabaseService(ctx, db1)
|
|
require.NoError(t, err)
|
|
listServicesResp, err = clt.ListResources(ctx,
|
|
proto.ListResourcesRequest{
|
|
ResourceType: types.KindDatabaseService,
|
|
Limit: apidefaults.DefaultChunkSize,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
out, err = types.ResourcesWithLabels(listServicesResp.Resources).AsDatabaseServices()
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.DatabaseService{db1, db2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Delete a DatabaseService.
|
|
err = clt.DeleteDatabaseService(ctx, db1.GetName())
|
|
require.NoError(t, err)
|
|
listServicesResp, err = clt.ListResources(ctx,
|
|
proto.ListResourcesRequest{
|
|
ResourceType: types.KindDatabaseService,
|
|
Limit: apidefaults.DefaultChunkSize,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
out, err = types.ResourcesWithLabels(listServicesResp.Resources).AsDatabaseServices()
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff([]types.DatabaseService{db2}, out,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Try to delete a DatabaseService that doesn't exist.
|
|
err = clt.DeleteDatabaseService(ctx, "doesnotexist")
|
|
require.IsType(t, trace.NotFound(""), err)
|
|
|
|
// Delete all DatabaseServices.
|
|
err = clt.DeleteAllDatabaseServices(ctx)
|
|
require.NoError(t, err)
|
|
listServicesResp, err = clt.ListResources(ctx,
|
|
proto.ListResourcesRequest{
|
|
ResourceType: types.KindDatabaseService,
|
|
Limit: apidefaults.DefaultChunkSize,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
out, err = types.ResourcesWithLabels(listServicesResp.Resources).AsDatabaseServices()
|
|
require.NoError(t, err)
|
|
require.Empty(t, out)
|
|
}
|
|
|
|
// TestSAMLIdPServiceProvidersCRUD tests SAMLIdPServiceProviders resource operations.
|
|
func TestSAMLIdPServiceProvidersCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
// Create two SAML IdP service providers.
|
|
sp1, err := types.NewSAMLIdPServiceProvider(
|
|
types.Metadata{
|
|
Name: "sp1",
|
|
},
|
|
types.SAMLIdPServiceProviderSpecV1{
|
|
EntityDescriptor: newEntityDescriptor("sp1"),
|
|
EntityID: "sp1",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sp2, err := types.NewSAMLIdPServiceProvider(
|
|
types.Metadata{
|
|
Name: "sp2",
|
|
},
|
|
types.SAMLIdPServiceProviderSpecV1{
|
|
EntityDescriptor: newEntityDescriptor("sp2"),
|
|
EntityID: "sp2",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Initially we expect no service providers.
|
|
listResp, nextKey, err := clt.ListSAMLIdPServiceProviders(ctx, 200, "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, nextKey)
|
|
require.Empty(t, listResp)
|
|
|
|
// Create both service providers
|
|
err = clt.CreateSAMLIdPServiceProvider(ctx, sp1)
|
|
require.NoError(t, err)
|
|
err = clt.CreateSAMLIdPServiceProvider(ctx, sp2)
|
|
require.NoError(t, err)
|
|
|
|
// Fetch all service providers
|
|
listResp, nextKey, err = clt.ListSAMLIdPServiceProviders(ctx, 200, "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, nextKey)
|
|
require.Empty(t, cmp.Diff([]types.SAMLIdPServiceProvider{sp1, sp2}, listResp,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Update a service provider.
|
|
sp1.SetEntityDescriptor(newEntityDescriptor("updated-sp1"))
|
|
sp1.SetEntityID("updated-sp1")
|
|
|
|
err = clt.UpdateSAMLIdPServiceProvider(ctx, sp1)
|
|
require.NoError(t, err)
|
|
listResp, nextKey, err = clt.ListSAMLIdPServiceProviders(ctx, 200, "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, nextKey)
|
|
require.Empty(t, cmp.Diff([]types.SAMLIdPServiceProvider{sp1, sp2}, listResp,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Delete a service provider.
|
|
err = clt.DeleteSAMLIdPServiceProvider(ctx, sp1.GetName())
|
|
require.NoError(t, err)
|
|
listResp, nextKey, err = clt.ListSAMLIdPServiceProviders(ctx, 200, "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, nextKey)
|
|
require.Empty(t, cmp.Diff([]types.SAMLIdPServiceProvider{sp2}, listResp,
|
|
cmpopts.IgnoreFields(types.Metadata{}, "ID"),
|
|
))
|
|
|
|
// Try to delete a service provider that doesn't exist.
|
|
err = clt.DeleteSAMLIdPServiceProvider(ctx, "doesnotexist")
|
|
require.True(t, trace.IsNotFound(err))
|
|
|
|
// Delete all service providers.
|
|
err = clt.DeleteAllSAMLIdPServiceProviders(ctx)
|
|
require.NoError(t, err)
|
|
listResp, nextKey, err = clt.ListSAMLIdPServiceProviders(ctx, 200, "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, nextKey)
|
|
require.Empty(t, listResp)
|
|
}
|
|
|
|
func TestListResources(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
srv := newTestTLSServer(t)
|
|
|
|
clt, err := srv.NewClient(TestAdmin())
|
|
require.NoError(t, err)
|
|
|
|
testCases := map[string]struct {
|
|
resourceType string
|
|
createResource func(name string, clt *Client) error
|
|
}{
|
|
"DatabaseServers": {
|
|
resourceType: types.KindDatabaseServer,
|
|
createResource: func(name string, clt *Client) error {
|
|
server, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: name,
|
|
}, types.DatabaseServerSpecV3{
|
|
Protocol: defaults.ProtocolPostgres,
|
|
URI: "localhost:5432",
|
|
Hostname: "localhost",
|
|
HostID: uuid.New().String(),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = clt.UpsertDatabaseServer(ctx, server)
|
|
return err
|
|
},
|
|
},
|
|
"ApplicationServers": {
|
|
resourceType: types.KindAppServer,
|
|
createResource: func(name string, clt *Client) error {
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: name,
|
|
}, types.AppSpecV3{
|
|
URI: "localhost",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
server, err := types.NewAppServerV3(types.Metadata{
|
|
Name: name,
|
|
}, types.AppServerSpecV3{
|
|
Hostname: "localhost",
|
|
HostID: uuid.New().String(),
|
|
App: app,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = clt.UpsertApplicationServer(ctx, server)
|
|
return err
|
|
},
|
|
},
|
|
"KubeServer": {
|
|
resourceType: types.KindKubeServer,
|
|
createResource: func(name string, clt *Client) error {
|
|
kube, err := types.NewKubernetesClusterV3(
|
|
types.Metadata{
|
|
Name: name,
|
|
Labels: map[string]string{"name": name},
|
|
},
|
|
types.KubernetesClusterSpecV3{},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
kubeServer, err := types.NewKubernetesServerV3FromCluster(kube, "_", "_")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = clt.UpsertKubernetesServer(ctx, kubeServer)
|
|
return err
|
|
},
|
|
},
|
|
"Node": {
|
|
resourceType: types.KindNode,
|
|
createResource: func(name string, clt *Client) error {
|
|
server, err := types.NewServer(name, types.KindNode, types.ServerSpecV2{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = clt.UpsertNode(ctx, server)
|
|
return err
|
|
},
|
|
},
|
|
"WindowsDesktops": {
|
|
resourceType: types.KindWindowsDesktop,
|
|
createResource: func(name string, clt *Client) error {
|
|
desktop, err := types.NewWindowsDesktopV3(name, nil,
|
|
types.WindowsDesktopSpecV3{Addr: "_", HostID: "_"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return clt.UpsertWindowsDesktop(ctx, desktop)
|
|
},
|
|
},
|
|
}
|
|
|
|
for name, test := range testCases {
|
|
name := name
|
|
test := test
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
resp, err := clt.ListResources(ctx, proto.ListResourcesRequest{
|
|
ResourceType: test.resourceType,
|
|
Namespace: apidefaults.Namespace,
|
|
Limit: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Resources, 0)
|
|
require.Empty(t, resp.NextKey)
|
|
|
|
// create two resources
|
|
err = test.createResource("foo", clt)
|
|
require.NoError(t, err)
|
|
err = test.createResource("bar", clt)
|
|
require.NoError(t, err)
|
|
|
|
resp, err = clt.ListResources(ctx, proto.ListResourcesRequest{
|
|
ResourceType: test.resourceType,
|
|
Namespace: apidefaults.Namespace,
|
|
Limit: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Resources, 2)
|
|
require.Empty(t, resp.NextKey)
|
|
require.Empty(t, resp.TotalCount)
|
|
|
|
// ListResources should also work when called on auth directly
|
|
resp, err = srv.Auth().ListResources(ctx, proto.ListResourcesRequest{
|
|
ResourceType: test.resourceType,
|
|
Namespace: apidefaults.Namespace,
|
|
Limit: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Resources, 2)
|
|
require.Empty(t, resp.NextKey)
|
|
require.Empty(t, resp.TotalCount)
|
|
|
|
// Test types.KindKubernetesCluster
|
|
if test.resourceType == types.KindKubeServer {
|
|
test.resourceType = types.KindKubernetesCluster
|
|
resp, err = clt.ListResources(ctx, proto.ListResourcesRequest{
|
|
ResourceType: test.resourceType,
|
|
Namespace: apidefaults.Namespace,
|
|
Limit: 100,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Resources, 2)
|
|
require.Empty(t, resp.NextKey)
|
|
require.Equal(t, 2, resp.TotalCount)
|
|
} else {
|
|
// Test listing with NeedTotalCount flag.
|
|
resp, err = clt.ListResources(ctx, proto.ListResourcesRequest{
|
|
ResourceType: test.resourceType,
|
|
Limit: 100,
|
|
NeedTotalCount: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Resources, 2)
|
|
require.Empty(t, resp.NextKey)
|
|
require.Equal(t, 2, resp.TotalCount)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("InvalidResourceType", func(t *testing.T) {
|
|
_, err := clt.ListResources(ctx, proto.ListResourcesRequest{
|
|
ResourceType: "",
|
|
Namespace: apidefaults.Namespace,
|
|
Limit: 100,
|
|
})
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestCustomRateLimiting(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
tests := []struct {
|
|
name string
|
|
burst int
|
|
fn func(*Client) error
|
|
}{
|
|
{
|
|
name: "RPC ChangeUserAuthentication",
|
|
fn: func(clt *Client) error {
|
|
_, err := clt.ChangeUserAuthentication(ctx, &proto.ChangeUserAuthenticationRequest{})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "RPC CreateAuthenticateChallenge",
|
|
burst: defaults.LimiterPasswordlessBurst,
|
|
fn: func(clt *Client) error {
|
|
_, err := clt.CreateAuthenticateChallenge(ctx, &proto.CreateAuthenticateChallengeRequest{})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "RPC GetAccountRecoveryToken",
|
|
fn: func(clt *Client) error {
|
|
_, err := clt.GetAccountRecoveryToken(ctx, &proto.GetAccountRecoveryTokenRequest{})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "RPC StartAccountRecovery",
|
|
fn: func(clt *Client) error {
|
|
_, err := clt.StartAccountRecovery(ctx, &proto.StartAccountRecoveryRequest{})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "RPC VerifyAccountRecovery",
|
|
fn: func(clt *Client) error {
|
|
_, err := clt.VerifyAccountRecovery(ctx, &proto.VerifyAccountRecoveryRequest{})
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create new instance per test case, to troubleshoot which test case
|
|
// specifically failed, otherwise multiple cases can fail from running
|
|
// cases in parallel.
|
|
srv := newTestTLSServer(t)
|
|
clt, err := srv.NewClient(TestNop())
|
|
require.NoError(t, err)
|
|
|
|
var attempts int
|
|
if test.burst == 0 {
|
|
attempts = 10 // Good for most tests.
|
|
} else {
|
|
attempts = test.burst
|
|
}
|
|
|
|
for i := 0; i < attempts; i++ {
|
|
err = test.fn(clt)
|
|
require.False(t, trace.IsLimitExceeded(err), "got err = %v, want non-IsLimitExceeded", err)
|
|
}
|
|
|
|
err = test.fn(clt)
|
|
require.True(t, trace.IsLimitExceeded(err), "got err = %v, want LimitExceeded", err)
|
|
})
|
|
}
|
|
}
|
|
|
|
type mockAuthorizer struct {
|
|
ctx *authz.Context
|
|
err error
|
|
}
|
|
|
|
func (a mockAuthorizer) Authorize(context.Context) (*authz.Context, error) {
|
|
return a.ctx, a.err
|
|
}
|
|
|
|
type mockTraceClient struct {
|
|
err error
|
|
spans []*otlptracev1.ResourceSpans
|
|
}
|
|
|
|
func (m mockTraceClient) Start(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (m mockTraceClient) Stop(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTraceClient) UploadTraces(ctx context.Context, protoSpans []*otlptracev1.ResourceSpans) error {
|
|
m.spans = protoSpans
|
|
|
|
return m.err
|
|
}
|
|
|
|
func TestExport(t *testing.T) {
|
|
t.Parallel()
|
|
uploadErr := trace.AccessDenied("failed to upload")
|
|
|
|
const user = "user"
|
|
|
|
validateResource := func(forwardedFor string, resourceSpan *otlptracev1.ResourceSpans) {
|
|
var forwarded []string
|
|
for _, attribute := range resourceSpan.Resource.Attributes {
|
|
if attribute.Key == forwardedTag {
|
|
forwarded = append(forwarded, attribute.Value.GetStringValue())
|
|
}
|
|
}
|
|
|
|
require.Len(t, forwarded, 1)
|
|
|
|
for _, scopeSpan := range resourceSpan.ScopeSpans {
|
|
for _, span := range scopeSpan.Spans {
|
|
for _, attribute := range span.Attributes {
|
|
if attribute.Key == forwardedTag {
|
|
forwarded = append(forwarded, attribute.Value.GetStringValue())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
require.Len(t, forwarded, 2)
|
|
for _, value := range forwarded {
|
|
require.Equal(t, forwardedFor, value)
|
|
}
|
|
}
|
|
|
|
validateTaggedSpans := func(forwardedFor string) require.ValueAssertionFunc {
|
|
return func(t require.TestingT, i interface{}, i2 ...interface{}) {
|
|
require.NotEmpty(t, i)
|
|
resourceSpans, ok := i.([]*otlptracev1.ResourceSpans)
|
|
require.True(t, ok)
|
|
|
|
for _, resourceSpan := range resourceSpans {
|
|
if resourceSpan.Resource != nil {
|
|
validateResource(forwardedFor, resourceSpan)
|
|
return
|
|
}
|
|
|
|
for _, scopeSpan := range resourceSpan.ScopeSpans {
|
|
for _, span := range scopeSpan.Spans {
|
|
var foundForwardedTag bool
|
|
for _, attribute := range span.Attributes {
|
|
if attribute.Key == forwardedTag {
|
|
require.False(t, foundForwardedTag)
|
|
foundForwardedTag = true
|
|
require.Equal(t, forwardedFor, attribute.Value.GetStringValue())
|
|
}
|
|
}
|
|
require.True(t, foundForwardedTag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
testSpans := []*otlptracev1.ResourceSpans{
|
|
{
|
|
Resource: &otlpresourcev1.Resource{
|
|
Attributes: []*otlpcommonv1.KeyValue{
|
|
{
|
|
Key: "test",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_IntValue{
|
|
IntValue: 1,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Key: "key",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_StringValue{
|
|
StringValue: user,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ScopeSpans: []*otlptracev1.ScopeSpans{
|
|
{
|
|
Spans: []*otlptracev1.Span{
|
|
{
|
|
Name: "with-attributes",
|
|
Attributes: []*otlpcommonv1.KeyValue{
|
|
{
|
|
Key: "test",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_IntValue{
|
|
IntValue: 1,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Key: "key",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_DoubleValue{
|
|
DoubleValue: 5.0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "with-tag",
|
|
Attributes: []*otlpcommonv1.KeyValue{{Key: forwardedTag, Value: &otlpcommonv1.AnyValue{Value: &otlpcommonv1.AnyValue_StringValue{StringValue: "test"}}}},
|
|
},
|
|
{
|
|
Name: "no-attributes",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ScopeSpans: []*otlptracev1.ScopeSpans{
|
|
{
|
|
Spans: []*otlptracev1.Span{
|
|
{
|
|
Name: "more-with-attributes",
|
|
Attributes: []*otlpcommonv1.KeyValue{
|
|
{
|
|
Key: "test2",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_IntValue{
|
|
IntValue: 11,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Key: "key2",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_DoubleValue{
|
|
DoubleValue: 15.0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "already-tagged",
|
|
Attributes: []*otlpcommonv1.KeyValue{
|
|
{
|
|
Key: forwardedTag,
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_StringValue{
|
|
StringValue: user,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Key: "key2",
|
|
Value: &otlpcommonv1.AnyValue{
|
|
Value: &otlpcommonv1.AnyValue_DoubleValue{
|
|
DoubleValue: 15.0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
identity TestIdentity
|
|
errAssertion require.ErrorAssertionFunc
|
|
uploadedAssertion require.ValueAssertionFunc
|
|
spans []*otlptracev1.ResourceSpans
|
|
authorizer authz.Authorizer
|
|
mockTraceClient mockTraceClient
|
|
}{
|
|
{
|
|
name: "error when unauthorized",
|
|
identity: TestNop(),
|
|
errAssertion: require.Error,
|
|
uploadedAssertion: require.Empty,
|
|
spans: make([]*otlptracev1.ResourceSpans, 1),
|
|
authorizer: &mockAuthorizer{err: trace.AccessDenied("unauthorized")},
|
|
},
|
|
{
|
|
name: "nop for empty spans",
|
|
identity: TestBuiltin(types.RoleNode),
|
|
errAssertion: require.NoError,
|
|
uploadedAssertion: require.Empty,
|
|
},
|
|
{
|
|
name: "failure to forward spans",
|
|
identity: TestBuiltin(types.RoleNode),
|
|
errAssertion: func(t require.TestingT, err error, i ...interface{}) {
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, trail.FromGRPC(trace.Unwrap(err)), uploadErr)
|
|
},
|
|
uploadedAssertion: func(t require.TestingT, i interface{}, i2 ...interface{}) {
|
|
require.NotNil(t, i)
|
|
require.Len(t, i, 1)
|
|
},
|
|
spans: make([]*otlptracev1.ResourceSpans, 1),
|
|
mockTraceClient: mockTraceClient{err: uploadErr},
|
|
},
|
|
{
|
|
name: "forwarded spans get tagged for system roles",
|
|
identity: TestBuiltin(types.RoleProxy),
|
|
errAssertion: require.NoError,
|
|
spans: testSpans,
|
|
uploadedAssertion: validateTaggedSpans(fmt.Sprintf("%s.localhost:%s", types.RoleProxy, types.RoleProxy)),
|
|
},
|
|
{
|
|
name: "forwarded spans get tagged for users",
|
|
identity: TestUser(user),
|
|
errAssertion: require.NoError,
|
|
spans: testSpans,
|
|
uploadedAssertion: validateTaggedSpans(user),
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
as, err := NewTestAuthServer(TestAuthServerConfig{
|
|
Dir: t.TempDir(),
|
|
Clock: clockwork.NewFakeClock(),
|
|
TraceClient: &tt.mockTraceClient,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
srv, err := as.NewTestTLSServer()
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { require.NoError(t, srv.Close()) })
|
|
|
|
// Create a fake user.
|
|
_, _, err = CreateUserAndRole(srv.Auth(), user, []string{"role"}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Setup the server
|
|
if tt.authorizer != nil {
|
|
srv.TLSServer.grpcServer.Authorizer = tt.authorizer
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Get a client for the test identity
|
|
clt, err := srv.NewClient(tt.identity)
|
|
require.NoError(t, err)
|
|
|
|
// create a tracing client and forward some traces
|
|
traceClt := tracing.NewClient(clt.APIClient.GetConnection())
|
|
t.Cleanup(func() { require.NoError(t, traceClt.Close()) })
|
|
require.NoError(t, traceClt.Start(ctx))
|
|
|
|
tt.errAssertion(t, traceClt.UploadTraces(ctx, tt.spans))
|
|
tt.uploadedAssertion(t, tt.mockTraceClient.spans)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGRPCServer_CreateToken tests the handler of the deprecated CreateToken
|
|
// RPC.
|
|
func TestGRPCServer_CreateToken(t *testing.T) {
|
|
ctx := context.Background()
|
|
server := newTestTLSServer(t)
|
|
|
|
// Allow us to directly invoke the deprecated gRPC methods with
|
|
// authentication.
|
|
user := TestAdmin()
|
|
ctx = authz.ContextWithUser(ctx, user.I)
|
|
|
|
// Test default expiry is applied.
|
|
t.Run("undefined-expiry", func(t *testing.T) {
|
|
tokenName := "undefined-expiry"
|
|
roles := types.SystemRoles{types.RoleNode}
|
|
token, err := types.NewProvisionToken(
|
|
tokenName,
|
|
roles,
|
|
time.Time{},
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = server.TLSServer.grpcServer.CreateToken(
|
|
ctx, token.(*types.ProvisionTokenV2),
|
|
)
|
|
require.NoError(t, err)
|
|
token, err = server.TLSServer.grpcServer.GetToken(
|
|
ctx, &types.ResourceRequest{Name: tokenName},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tokenName, token.GetName())
|
|
ttl := token.Expiry().Sub(server.Clock().Now())
|
|
defaultTTL := defaults.ProvisioningTokenTTL
|
|
require.LessOrEqual(t, ttl, defaultTTL)
|
|
require.GreaterOrEqual(t, ttl, defaultTTL-(time.Second*10))
|
|
})
|
|
|
|
// Test set expiry is applied.
|
|
t.Run("set-expiry", func(t *testing.T) {
|
|
tokenName := "set-expiry"
|
|
roles := types.SystemRoles{types.RoleNode}
|
|
ttl := time.Hour * 24
|
|
token, err := types.NewProvisionToken(
|
|
tokenName,
|
|
roles,
|
|
server.Clock().Now().Add(ttl),
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = server.TLSServer.grpcServer.CreateToken(
|
|
ctx, token.(*types.ProvisionTokenV2),
|
|
)
|
|
require.NoError(t, err)
|
|
token, err = server.TLSServer.grpcServer.GetToken(
|
|
ctx, &types.ResourceRequest{Name: tokenName},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tokenName, token.GetName())
|
|
actualTTL := token.Expiry().Sub(server.Clock().Now())
|
|
require.LessOrEqual(t, actualTTL, ttl)
|
|
require.GreaterOrEqual(t, actualTTL, ttl-(time.Second*10))
|
|
})
|
|
|
|
// Test expiry in past is changed to default
|
|
t.Run("past-expiry", func(t *testing.T) {
|
|
tokenName := "past-expiry"
|
|
roles := types.SystemRoles{types.RoleNode}
|
|
token, err := types.NewProvisionToken(
|
|
tokenName,
|
|
roles,
|
|
server.Clock().Now().Add(-1*time.Hour),
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = server.TLSServer.grpcServer.CreateToken(
|
|
ctx, token.(*types.ProvisionTokenV2),
|
|
)
|
|
require.NoError(t, err)
|
|
token, err = server.TLSServer.grpcServer.GetToken(
|
|
ctx, &types.ResourceRequest{Name: tokenName},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tokenName, token.GetName())
|
|
ttl := token.Expiry().Sub(server.Clock().Now())
|
|
defaultTTL := defaults.ProvisioningTokenTTL
|
|
require.LessOrEqual(t, ttl, defaultTTL)
|
|
require.GreaterOrEqual(t, ttl, defaultTTL-(time.Second*10))
|
|
})
|
|
}
|
|
|
|
// TestGRPCServer_UpsertToken tests the handler of the deprecated CreateToken
|
|
// RPC.
|
|
func TestGRPCServer_UpsertToken(t *testing.T) {
|
|
ctx := context.Background()
|
|
server := newTestTLSServer(t)
|
|
|
|
// Allow us to directly invoke the deprecated gRPC methods with
|
|
// authentication.
|
|
user := TestAdmin()
|
|
ctx = authz.ContextWithUser(ctx, user.I)
|
|
|
|
// Test default expiry is applied.
|
|
t.Run("undefined-expiry", func(t *testing.T) {
|
|
tokenName := "undefined-expiry"
|
|
roles := types.SystemRoles{types.RoleNode}
|
|
token, err := types.NewProvisionToken(
|
|
tokenName,
|
|
roles,
|
|
time.Time{},
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = server.TLSServer.grpcServer.UpsertToken(
|
|
ctx, token.(*types.ProvisionTokenV2),
|
|
)
|
|
require.NoError(t, err)
|
|
token, err = server.TLSServer.grpcServer.GetToken(
|
|
ctx, &types.ResourceRequest{Name: tokenName},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tokenName, token.GetName())
|
|
ttl := token.Expiry().Sub(server.Clock().Now())
|
|
defaultTTL := defaults.ProvisioningTokenTTL
|
|
require.LessOrEqual(t, ttl, defaultTTL)
|
|
require.GreaterOrEqual(t, ttl, defaultTTL-(time.Second*10))
|
|
})
|
|
|
|
// Test set expiry is applied.
|
|
t.Run("set-expiry", func(t *testing.T) {
|
|
tokenName := "set-expiry"
|
|
roles := types.SystemRoles{types.RoleNode}
|
|
ttl := time.Hour * 24
|
|
token, err := types.NewProvisionToken(
|
|
tokenName,
|
|
roles,
|
|
server.Clock().Now().Add(ttl),
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = server.TLSServer.grpcServer.UpsertToken(
|
|
ctx, token.(*types.ProvisionTokenV2),
|
|
)
|
|
require.NoError(t, err)
|
|
token, err = server.TLSServer.grpcServer.GetToken(
|
|
ctx, &types.ResourceRequest{Name: tokenName},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tokenName, token.GetName())
|
|
actualTTL := token.Expiry().Sub(server.Clock().Now())
|
|
require.LessOrEqual(t, actualTTL, ttl)
|
|
require.GreaterOrEqual(t, actualTTL, ttl-(time.Second*10))
|
|
})
|
|
|
|
// Test expiry in past is changed to default
|
|
t.Run("past-expiry", func(t *testing.T) {
|
|
tokenName := "past-expiry"
|
|
roles := types.SystemRoles{types.RoleNode}
|
|
token, err := types.NewProvisionToken(
|
|
tokenName,
|
|
roles,
|
|
server.Clock().Now().Add(-1*time.Hour),
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = server.TLSServer.grpcServer.UpsertToken(
|
|
ctx, token.(*types.ProvisionTokenV2),
|
|
)
|
|
require.NoError(t, err)
|
|
token, err = server.TLSServer.grpcServer.GetToken(
|
|
ctx, &types.ResourceRequest{Name: tokenName},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tokenName, token.GetName())
|
|
ttl := token.Expiry().Sub(server.Clock().Now())
|
|
defaultTTL := defaults.ProvisioningTokenTTL
|
|
require.LessOrEqual(t, ttl, defaultTTL)
|
|
require.GreaterOrEqual(t, ttl, defaultTTL-(time.Second*10))
|
|
})
|
|
}
|
|
|
|
// TestSAMLValidation tests that SAML validation does not perform an HTTP
|
|
// request if the calling user does not have permissions to create or update
|
|
// a SAML connector.
|
|
func TestSAMLValidation(t *testing.T) {
|
|
modules.SetTestModules(t, &modules.TestModules{
|
|
TestFeatures: modules.Features{SAML: true},
|
|
})
|
|
|
|
// minimal entity_descriptor to pass validation. not actually valid
|
|
const minimalEntityDescriptor = `
|
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://example.com">
|
|
<md:IDPSSODescriptor>
|
|
<md:SingleSignOnService Location="http://example.com" />
|
|
</md:IDPSSODescriptor>
|
|
</md:EntityDescriptor>`
|
|
|
|
allowSAMLUpsert := types.RoleConditions{
|
|
Rules: []types.Rule{{
|
|
Resources: []string{types.KindSAML},
|
|
Verbs: []string{types.VerbCreate, types.VerbUpdate},
|
|
}},
|
|
}
|
|
|
|
testCases := []struct {
|
|
desc string
|
|
allow types.RoleConditions
|
|
entityDescriptor string
|
|
entityServerCalled bool
|
|
assertErr func(error) bool
|
|
}{
|
|
{
|
|
desc: "access denied",
|
|
allow: types.RoleConditions{},
|
|
entityServerCalled: false,
|
|
assertErr: trace.IsAccessDenied,
|
|
},
|
|
{
|
|
desc: "validation failure",
|
|
allow: allowSAMLUpsert,
|
|
entityDescriptor: "", // validation fails with no issuer
|
|
entityServerCalled: true,
|
|
assertErr: trace.IsBadParameter,
|
|
},
|
|
{
|
|
desc: "access permitted",
|
|
allow: allowSAMLUpsert,
|
|
entityDescriptor: minimalEntityDescriptor,
|
|
entityServerCalled: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
server := newTestTLSServer(t)
|
|
// Create an http server to serve the entity descriptor url
|
|
entityServerCalled := false
|
|
entityServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
entityServerCalled = true
|
|
_, err := w.Write([]byte(tc.entityDescriptor))
|
|
require.NoError(t, err)
|
|
}))
|
|
|
|
role, err := CreateRole(ctx, server.Auth(), "test_role", types.RoleSpecV6{Allow: tc.allow})
|
|
require.NoError(t, err)
|
|
user, err := CreateUser(server.Auth(), "test_user", role)
|
|
require.NoError(t, err)
|
|
|
|
connector, err := types.NewSAMLConnector("test_connector", types.SAMLConnectorSpecV2{
|
|
AssertionConsumerService: "http://localhost:65535/acs", // not called
|
|
EntityDescriptorURL: entityServer.URL,
|
|
AttributesToRoles: []types.AttributeMapping{
|
|
// not used. can be any name, value but role must exist
|
|
{Name: "groups", Value: "admin", Roles: []string{role.GetName()}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client, err := server.NewClient(TestUser(user.GetName()))
|
|
require.NoError(t, err)
|
|
|
|
err = client.UpsertSAMLConnector(ctx, connector)
|
|
|
|
if tc.assertErr != nil {
|
|
require.Error(t, err)
|
|
require.True(t, tc.assertErr(err), "UpsertSAMLConnector error type mismatch. got: %T", trace.Unwrap(err))
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
if tc.entityServerCalled {
|
|
require.True(t, entityServerCalled, "entity_descriptor_url was not called")
|
|
} else {
|
|
require.False(t, entityServerCalled, "entity_descriptor_url was called")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newEntityDescriptor(entityID string) string {
|
|
return fmt.Sprintf(testEntityDescriptor, entityID)
|
|
}
|
|
|
|
// A test entity descriptor from https://sptest.iamshowcase.com/testsp_metadata.xml.
|
|
const testEntityDescriptor = `
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="%s" validUntil="2025-12-09T09:13:31.006Z">
|
|
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
|
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sptest.iamshowcase.com/acs" index="0" isDefault="true"/>
|
|
</md:SPSSODescriptor>
|
|
</md:EntityDescriptor>
|
|
`
|
|
|
|
func TestGRPCServer_GetInstallers(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
server := newTestTLSServer(t)
|
|
grpc := server.TLSServer.grpcServer
|
|
|
|
user := TestAdmin()
|
|
ctx = authz.ContextWithUser(ctx, user.I)
|
|
|
|
tests := []struct {
|
|
name string
|
|
inputInstallers map[string]string
|
|
expectedInstallers map[string]string
|
|
}{
|
|
{
|
|
name: "default installers only",
|
|
expectedInstallers: map[string]string{
|
|
installers.InstallerScriptName: installers.DefaultInstaller.GetScript(),
|
|
installers.InstallerScriptNameAgentless: installers.DefaultAgentlessInstaller.GetScript(),
|
|
},
|
|
},
|
|
{
|
|
name: "default and custom installers",
|
|
inputInstallers: map[string]string{
|
|
"my-custom-installer": "echo test",
|
|
},
|
|
expectedInstallers: map[string]string{
|
|
"my-custom-installer": "echo test",
|
|
installers.InstallerScriptName: installers.DefaultInstaller.GetScript(),
|
|
installers.InstallerScriptNameAgentless: installers.DefaultAgentlessInstaller.GetScript(),
|
|
},
|
|
},
|
|
{
|
|
name: "override default installer",
|
|
inputInstallers: map[string]string{
|
|
installers.InstallerScriptName: "echo test",
|
|
},
|
|
expectedInstallers: map[string]string{
|
|
installers.InstallerScriptName: "echo test",
|
|
installers.InstallerScriptNameAgentless: installers.DefaultAgentlessInstaller.GetScript(),
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Cleanup(func() {
|
|
_, err := grpc.DeleteAllInstallers(ctx, &emptypb.Empty{})
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
for name, script := range tc.inputInstallers {
|
|
installer, err := types.NewInstallerV1(name, script)
|
|
require.NoError(t, err)
|
|
_, err = grpc.SetInstaller(ctx, installer)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
outputInstallerList, err := grpc.GetInstallers(ctx, &emptypb.Empty{})
|
|
require.NoError(t, err)
|
|
outputInstallers := make(map[string]string, len(tc.expectedInstallers))
|
|
for _, installer := range outputInstallerList.Installers {
|
|
outputInstallers[installer.GetName()] = installer.GetScript()
|
|
}
|
|
|
|
require.Equal(t, tc.expectedInstallers, outputInstallers)
|
|
})
|
|
}
|
|
|
|
}
|