mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-04 02:21:02 +08:00
* Reorganize tctl commands to have commands not required auth client * Replace auth client with lazy loading approach * Fix linter warning * Replace camel case in import alias Replace logrus to use slog * Rename close function * Refactor plugin commands to use interface of auth client and plugin client Code review changes * Refactor workload identity commands * Add access to global config for the commands * Add test checking all tctl commands match process * Fix golangci-lint warnings
524 lines
17 KiB
Go
524 lines
17 KiB
Go
// Teleport
|
|
// Copyright (C) 2024 Gravitational, Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/gravitational/trace"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
pluginsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1"
|
|
"github.com/gravitational/teleport/api/mfa"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/lib/service/servicecfg"
|
|
)
|
|
|
|
func TestPluginsInstallOkta(t *testing.T) {
|
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
cmd PluginsCommand
|
|
expectSAMLConnectorQuery string
|
|
expectPing bool
|
|
expectRequest *pluginsv1.CreatePluginRequest
|
|
expectError require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "AccessList sync requires at least one default owner",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta",
|
|
okta: oktaArgs{
|
|
accessListSync: true,
|
|
},
|
|
},
|
|
},
|
|
expectError: requireBadParameter,
|
|
},
|
|
{
|
|
name: "SCIM sync requires at least one default owner",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta",
|
|
okta: oktaArgs{
|
|
samlConnector: "fake-saml-connector",
|
|
scimToken: "i am a scim token",
|
|
appID: "okta app ID goes here",
|
|
},
|
|
},
|
|
},
|
|
expectError: requireBadParameter,
|
|
},
|
|
{
|
|
name: "SCIM sync requires appID",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta",
|
|
okta: oktaArgs{
|
|
samlConnector: "fake-saml-connector",
|
|
scimToken: "i am a scim token",
|
|
defaultOwners: []string{"admin"},
|
|
scimEnabled: true,
|
|
userSync: true,
|
|
apiToken: "api-token-goes-here",
|
|
},
|
|
},
|
|
},
|
|
expectSAMLConnectorQuery: "fake-saml-connector",
|
|
expectError: requireBadParameter,
|
|
},
|
|
{
|
|
name: "Bare bones install succeeds",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta-barebones-test",
|
|
okta: oktaArgs{
|
|
org: mustParseURL("https://example.okta.com"),
|
|
samlConnector: "okta-integration",
|
|
apiToken: "api-token-goes-here",
|
|
appGroupSync: true,
|
|
},
|
|
},
|
|
},
|
|
expectSAMLConnectorQuery: "okta-integration",
|
|
expectRequest: &pluginsv1.CreatePluginRequest{
|
|
Plugin: &types.PluginV1{
|
|
SubKind: types.PluginSubkindAccess,
|
|
Metadata: types.Metadata{
|
|
Labels: map[string]string{
|
|
types.HostedPluginLabel: "true",
|
|
},
|
|
Name: "okta-barebones-test",
|
|
},
|
|
Spec: types.PluginSpecV1{
|
|
Settings: &types.PluginSpecV1_Okta{
|
|
Okta: &types.PluginOktaSettings{
|
|
OrgUrl: "https://example.okta.com",
|
|
SyncSettings: &types.PluginOktaSyncSettings{
|
|
SsoConnectorId: "okta-integration",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
StaticCredentialsList: []*types.PluginStaticCredentialsV1{
|
|
{
|
|
ResourceHeader: types.ResourceHeader{
|
|
Metadata: types.Metadata{
|
|
Name: "okta-barebones-test",
|
|
Labels: map[string]string{
|
|
types.OktaCredPurposeLabel: types.OktaCredPurposeAuth,
|
|
},
|
|
},
|
|
},
|
|
Spec: &types.PluginStaticCredentialsSpecV1{
|
|
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
|
|
APIToken: "api-token-goes-here",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CredentialLabels: map[string]string{
|
|
types.OktaOrgURLLabel: "https://example.okta.com",
|
|
},
|
|
},
|
|
expectError: require.NoError,
|
|
},
|
|
{
|
|
name: "Sync service enabled",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta-sync-service-test",
|
|
okta: oktaArgs{
|
|
org: mustParseURL("https://example.okta.com"),
|
|
apiToken: "api-token-goes-here",
|
|
samlConnector: "saml-connector-name",
|
|
userSync: true,
|
|
accessListSync: true,
|
|
appGroupSync: true,
|
|
defaultOwners: []string{"admin"},
|
|
groupFilters: []string{"group-alpha", "group-beta"},
|
|
appFilters: []string{"app-gamma", "app-delta", "app-epsilon"},
|
|
},
|
|
},
|
|
},
|
|
expectSAMLConnectorQuery: "saml-connector-name",
|
|
expectRequest: &pluginsv1.CreatePluginRequest{
|
|
Plugin: &types.PluginV1{
|
|
SubKind: types.PluginSubkindAccess,
|
|
Metadata: types.Metadata{
|
|
Labels: map[string]string{
|
|
types.HostedPluginLabel: "true",
|
|
},
|
|
Name: "okta-sync-service-test",
|
|
},
|
|
Spec: types.PluginSpecV1{
|
|
Settings: &types.PluginSpecV1_Okta{
|
|
Okta: &types.PluginOktaSettings{
|
|
OrgUrl: "https://example.okta.com",
|
|
SyncSettings: &types.PluginOktaSyncSettings{
|
|
SyncUsers: true,
|
|
SyncAccessLists: true,
|
|
SsoConnectorId: "saml-connector-name",
|
|
DefaultOwners: []string{"admin"},
|
|
GroupFilters: []string{"group-alpha", "group-beta"},
|
|
AppFilters: []string{"app-gamma", "app-delta", "app-epsilon"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
StaticCredentialsList: []*types.PluginStaticCredentialsV1{
|
|
{
|
|
ResourceHeader: types.ResourceHeader{
|
|
Metadata: types.Metadata{
|
|
Name: "okta-sync-service-test",
|
|
Labels: map[string]string{
|
|
types.OktaCredPurposeLabel: types.OktaCredPurposeAuth,
|
|
},
|
|
},
|
|
},
|
|
Spec: &types.PluginStaticCredentialsSpecV1{
|
|
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
|
|
APIToken: "api-token-goes-here",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CredentialLabels: map[string]string{
|
|
types.OktaOrgURLLabel: "https://example.okta.com",
|
|
},
|
|
},
|
|
expectError: require.NoError,
|
|
},
|
|
{
|
|
name: "SCIM service enabled",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta-scim-test",
|
|
okta: oktaArgs{
|
|
org: mustParseURL("https://example.okta.com"),
|
|
apiToken: "api-token-goes-here",
|
|
appID: "okta-app-id",
|
|
samlConnector: "teleport-saml-connector-id",
|
|
scimToken: "i am a scim token",
|
|
userSync: true,
|
|
accessListSync: true,
|
|
appGroupSync: true,
|
|
defaultOwners: []string{"admin"},
|
|
groupFilters: []string{"group-alpha", "group-beta"},
|
|
appFilters: []string{"app-gamma", "app-delta", "app-epsilon"},
|
|
},
|
|
},
|
|
},
|
|
expectPing: true,
|
|
expectSAMLConnectorQuery: "teleport-saml-connector-id",
|
|
expectRequest: &pluginsv1.CreatePluginRequest{
|
|
Plugin: &types.PluginV1{
|
|
SubKind: types.PluginSubkindAccess,
|
|
Metadata: types.Metadata{
|
|
Labels: map[string]string{
|
|
types.HostedPluginLabel: "true",
|
|
},
|
|
Name: "okta-scim-test",
|
|
},
|
|
Spec: types.PluginSpecV1{
|
|
Settings: &types.PluginSpecV1_Okta{
|
|
Okta: &types.PluginOktaSettings{
|
|
OrgUrl: "https://example.okta.com",
|
|
SyncSettings: &types.PluginOktaSyncSettings{
|
|
AppId: "okta-app-id",
|
|
SsoConnectorId: "teleport-saml-connector-id",
|
|
SyncUsers: true,
|
|
SyncAccessLists: true,
|
|
DefaultOwners: []string{"admin"},
|
|
GroupFilters: []string{"group-alpha", "group-beta"},
|
|
AppFilters: []string{"app-gamma", "app-delta", "app-epsilon"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
StaticCredentialsList: []*types.PluginStaticCredentialsV1{
|
|
{
|
|
ResourceHeader: types.ResourceHeader{
|
|
Metadata: types.Metadata{
|
|
Name: "okta-scim-test",
|
|
Labels: map[string]string{
|
|
types.OktaCredPurposeLabel: types.OktaCredPurposeAuth,
|
|
},
|
|
},
|
|
},
|
|
Spec: &types.PluginStaticCredentialsSpecV1{
|
|
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
|
|
APIToken: "api-token-goes-here",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ResourceHeader: types.ResourceHeader{
|
|
Metadata: types.Metadata{
|
|
Name: "okta-scim-test-scim-token",
|
|
Labels: map[string]string{
|
|
types.OktaCredPurposeLabel: types.OktaCredPurposeSCIMToken,
|
|
},
|
|
},
|
|
},
|
|
Spec: &types.PluginStaticCredentialsSpecV1{
|
|
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
|
|
APIToken: "scim-token-goes-here",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CredentialLabels: map[string]string{
|
|
types.OktaOrgURLLabel: "https://example.okta.com",
|
|
},
|
|
},
|
|
expectError: require.NoError,
|
|
},
|
|
{
|
|
name: "app group sync sync disabled should send okta-auth-scim-only creds",
|
|
cmd: PluginsCommand{
|
|
install: pluginInstallArgs{
|
|
name: "okta-barebones-test",
|
|
okta: oktaArgs{
|
|
org: mustParseURL("https://example.okta.com"),
|
|
samlConnector: "okta-integration",
|
|
apiToken: "api-token-goes-here",
|
|
appGroupSync: false,
|
|
scimToken: "OktaCredPurposeSCIMToken",
|
|
},
|
|
},
|
|
},
|
|
expectSAMLConnectorQuery: "okta-integration",
|
|
expectPing: true,
|
|
expectRequest: &pluginsv1.CreatePluginRequest{
|
|
Plugin: &types.PluginV1{
|
|
SubKind: types.PluginSubkindAccess,
|
|
Metadata: types.Metadata{
|
|
Labels: map[string]string{
|
|
types.HostedPluginLabel: "true",
|
|
},
|
|
Name: "okta-barebones-test",
|
|
},
|
|
Spec: types.PluginSpecV1{
|
|
Settings: &types.PluginSpecV1_Okta{
|
|
Okta: &types.PluginOktaSettings{
|
|
OrgUrl: "https://example.okta.com",
|
|
SyncSettings: &types.PluginOktaSyncSettings{
|
|
SsoConnectorId: "okta-integration",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
StaticCredentialsList: []*types.PluginStaticCredentialsV1{
|
|
{
|
|
ResourceHeader: types.ResourceHeader{
|
|
Metadata: types.Metadata{
|
|
Name: "okta-barebones-test",
|
|
Labels: map[string]string{
|
|
types.OktaCredPurposeLabel: types.CredPurposeOKTAAPITokenWithSCIMOnlyIntegration,
|
|
},
|
|
},
|
|
},
|
|
Spec: &types.PluginStaticCredentialsSpecV1{
|
|
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
|
|
APIToken: "api-token-goes-here",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
CredentialLabels: map[string]string{
|
|
types.OktaOrgURLLabel: "https://example.okta.com",
|
|
},
|
|
},
|
|
expectError: require.NoError,
|
|
},
|
|
}
|
|
|
|
cmpOptions := []cmp.Option{
|
|
// Ignore extraneous fields for protobuf bookkeeping
|
|
cmpopts.IgnoreUnexported(pluginsv1.CreatePluginRequest{}),
|
|
|
|
// Ignore any SCIM-token credentials because the bcrypt hash of the token
|
|
// will change on every run.
|
|
// TODO: Find a way to only exclude the token hash from the comparison,
|
|
// rather than the whole credential
|
|
cmpopts.IgnoreSliceElements(func(c *types.PluginStaticCredentialsV1) bool {
|
|
l, _ := c.GetLabel(types.OktaCredPurposeLabel)
|
|
return l == types.OktaCredPurposeSCIMToken
|
|
}),
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
var args installPluginArgs
|
|
|
|
if testCase.expectRequest != nil {
|
|
pluginsClient := &mockPluginsClient{}
|
|
t.Cleanup(func() { pluginsClient.AssertExpectations(t) })
|
|
|
|
pluginsClient.
|
|
On("CreatePlugin", anyContext, mock.Anything, mock.Anything).
|
|
Run(func(args mock.Arguments) {
|
|
require.IsType(t, (*pluginsv1.CreatePluginRequest)(nil), args.Get(1))
|
|
request := args.Get(1).(*pluginsv1.CreatePluginRequest)
|
|
require.Empty(t, cmp.Diff(testCase.expectRequest, request, cmpOptions...))
|
|
}).
|
|
Return(&emptypb.Empty{}, nil)
|
|
|
|
args.plugins = pluginsClient
|
|
}
|
|
|
|
authClient := &mockAuthClient{}
|
|
if testCase.expectSAMLConnectorQuery != "" {
|
|
t.Cleanup(func() { authClient.AssertExpectations(t) })
|
|
|
|
authClient.
|
|
On("GetSAMLConnector", anyContext, testCase.expectSAMLConnectorQuery, false).
|
|
Return(&types.SAMLConnectorV2{}, nil)
|
|
|
|
args.authClient = authClient
|
|
}
|
|
|
|
if testCase.expectPing {
|
|
authClient.
|
|
On("Ping", anyContext).
|
|
Return(proto.PingResponse{
|
|
ProxyPublicAddr: "example.com",
|
|
}, nil)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
testCase.cmd.config = &servicecfg.Config{
|
|
Logger: slog.Default().With("test", t.Name()),
|
|
}
|
|
|
|
err := testCase.cmd.InstallOkta(ctx, args)
|
|
testCase.expectError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func requireBadParameter(t require.TestingT, err error, _ ...any) {
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsBadParameter(err), "Expecting bad parameter, got %T: \"%v\"", err, err)
|
|
}
|
|
|
|
func mustParseURL(text string) *url.URL {
|
|
url, err := url.Parse(text)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return url
|
|
}
|
|
|
|
type mockPluginsClient struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockPluginsClient) CreatePlugin(ctx context.Context, in *pluginsv1.CreatePluginRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
|
result := m.Called(ctx, in, opts)
|
|
return result.Get(0).(*emptypb.Empty), result.Error(1)
|
|
}
|
|
|
|
func (m *mockPluginsClient) GetPlugin(ctx context.Context, in *pluginsv1.GetPluginRequest, opts ...grpc.CallOption) (*types.PluginV1, error) {
|
|
result := m.Called(ctx, in, opts)
|
|
return result.Get(0).(*types.PluginV1), result.Error(1)
|
|
}
|
|
|
|
func (m *mockPluginsClient) UpdatePlugin(ctx context.Context, in *pluginsv1.UpdatePluginRequest, opts ...grpc.CallOption) (*types.PluginV1, error) {
|
|
result := m.Called(ctx, in, opts)
|
|
return result.Get(0).(*types.PluginV1), result.Error(1)
|
|
}
|
|
|
|
func (m *mockPluginsClient) NeedsCleanup(ctx context.Context, in *pluginsv1.NeedsCleanupRequest, opts ...grpc.CallOption) (*pluginsv1.NeedsCleanupResponse, error) {
|
|
result := m.Called(ctx, in, opts)
|
|
return result.Get(0).(*pluginsv1.NeedsCleanupResponse), result.Error(1)
|
|
}
|
|
|
|
func (m *mockPluginsClient) Cleanup(ctx context.Context, in *pluginsv1.CleanupRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
|
result := m.Called(ctx, in, opts)
|
|
return result.Get(0).(*emptypb.Empty), result.Error(1)
|
|
}
|
|
|
|
func (m *mockPluginsClient) DeletePlugin(ctx context.Context, in *pluginsv1.DeletePluginRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
|
result := m.Called(ctx, in, opts)
|
|
return result.Get(0).(*emptypb.Empty), result.Error(1)
|
|
}
|
|
|
|
type mockAuthClient struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockAuthClient) GetSAMLConnector(ctx context.Context, id string, withSecrets bool) (types.SAMLConnector, error) {
|
|
result := m.Called(ctx, id, withSecrets)
|
|
return result.Get(0).(types.SAMLConnector), result.Error(1)
|
|
}
|
|
func (m *mockAuthClient) CreateSAMLConnector(ctx context.Context, connector types.SAMLConnector) (types.SAMLConnector, error) {
|
|
result := m.Called(ctx, connector)
|
|
return result.Get(0).(types.SAMLConnector), result.Error(1)
|
|
}
|
|
func (m *mockAuthClient) UpsertSAMLConnector(ctx context.Context, connector types.SAMLConnector) (types.SAMLConnector, error) {
|
|
result := m.Called(ctx, connector)
|
|
return result.Get(0).(types.SAMLConnector), result.Error(1)
|
|
}
|
|
func (m *mockAuthClient) CreateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) {
|
|
result := m.Called(ctx, ig)
|
|
return result.Get(0).(types.Integration), result.Error(1)
|
|
}
|
|
func (m *mockAuthClient) UpdateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) {
|
|
result := m.Called(ctx, ig)
|
|
return result.Get(0).(types.Integration), result.Error(1)
|
|
}
|
|
|
|
func (m *mockAuthClient) GetIntegration(ctx context.Context, name string) (types.Integration, error) {
|
|
result := m.Called(ctx, name)
|
|
return result.Get(0).(types.Integration), result.Error(1)
|
|
}
|
|
|
|
func (m *mockAuthClient) Ping(ctx context.Context) (proto.PingResponse, error) {
|
|
result := m.Called(ctx)
|
|
return result.Get(0).(proto.PingResponse), result.Error(1)
|
|
}
|
|
|
|
func (m *mockAuthClient) PerformMFACeremony(ctx context.Context, challengeRequest *proto.CreateAuthenticateChallengeRequest, promptOpts ...mfa.PromptOpt) (*proto.MFAAuthenticateResponse, error) {
|
|
return &proto.MFAAuthenticateResponse{}, nil
|
|
}
|
|
|
|
func (m *mockAuthClient) GetRole(ctx context.Context, name string) (types.Role, error) {
|
|
result := m.Called(ctx, name)
|
|
return result.Get(0).(types.Role), result.Error(1)
|
|
}
|
|
|
|
// anyContext is an argument matcher for testify mocks that matches any context.
|
|
var anyContext any = mock.MatchedBy(func(context.Context) bool { return true })
|