diff --git a/api/types/constants.go b/api/types/constants.go index e313552723f3b..dc658c5cd96aa 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -1253,6 +1253,28 @@ const ( HostedPluginLabel = TeleportNamespace + "/hosted-plugin" ) +const ( + // OktaOrgURLLabel is the label used by Okta-managed resources to indicate + // the upstream Okta organization that they come from. + OktaOrgURLLabel = "okta/org" + + // OktaAppIDLabel is the label for the Okta application ID on appserver objects. + OktaAppIDLabel = TeleportInternalLabelPrefix + "okta-app-id" + + // OktaCredPurposeLabel is used by Okta-managed PluginStaticCredentials to + // indicate their purpose + OktaCredPurposeLabel = "okta/purpose" + + // OktaCredPurposeAuth indicates that the credential is intended for + // authenticating with the Okta REST API + OktaCredPurposeAuth = "okta-auth" + + // OktaCredPurposeSCIMToken indicates that theis to be used for authenticating + // SCIM requests from the upstream organization. The content of the credential + // is a bcrypt hash of actual token. + OktaCredPurposeSCIMToken = "scim-bearer-token" +) + const ( // SCIMBaseURLLabel defines a label indicating the base URL for // interacting with a plugin via SCIM. Useful for diagnostic display. diff --git a/tool/tctl/common/plugins_command.go b/tool/tctl/common/plugins_command.go index f38206aa26cb4..753a70809020d 100644 --- a/tool/tctl/common/plugins_command.go +++ b/tool/tctl/common/plugins_command.go @@ -22,10 +22,13 @@ import ( "context" "fmt" "log/slog" + "net/url" "github.com/alecthomas/kingpin/v2" "github.com/gravitational/trace" "golang.org/x/crypto/bcrypt" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/emptypb" "github.com/gravitational/teleport" pluginsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1" @@ -48,9 +51,24 @@ func logErrorMessage(err error) slog.Attr { type pluginInstallArgs struct { cmd *kingpin.CmdClause name string + okta oktaArgs scim scimArgs } +type oktaArgs struct { + cmd *kingpin.CmdClause + org *url.URL + appID string + samlConnector string + apiToken string + scimToken string + userSync bool + accessListSync bool + defaultOwners []string + appFilters []string + groupFilters []string +} + type scimArgs struct { cmd *kingpin.CmdClause samlConnector string @@ -85,13 +103,64 @@ func (p *PluginsCommand) Initialize(app *kingpin.Application, config *servicecfg p.cleanupCmd.Arg("type", "The type of plugin to cleanup. Only supports okta at present.").Required().EnumVar(&p.pluginType, string(types.PluginTypeOkta)) p.cleanupCmd.Flag("dry-run", "Dry run the cleanup command. Dry run defaults to on.").Default("true").BoolVar(&p.dryRun) - p.initInstall(pluginsCommand) + p.initInstall(pluginsCommand, config) p.initDelete(pluginsCommand) } -func (p *PluginsCommand) initInstall(parent *kingpin.CmdClause) { +func (p *PluginsCommand) initInstall(parent *kingpin.CmdClause, config *servicecfg.Config) { p.install.cmd = parent.Command("install", "Install new plugin instance") + p.initInstallOkta(p.install.cmd) + p.initInstallSCIM(p.install.cmd) +} + +func (p *PluginsCommand) initInstallOkta(parent *kingpin.CmdClause) { + p.install.okta.cmd = parent.Command("okta", "Install an okta integration") + p.install.okta.cmd. + Flag("name", "Name of the plugin resource to create"). + Default("okta"). + StringVar(&p.install.name) + p.install.okta.cmd. + Flag("org", "URL of Okta organization"). + Required(). + URLVar(&p.install.okta.org) + p.install.okta.cmd. + Flag("api-token", "Okta API token for the plugin to use"). + Required(). + StringVar(&p.install.okta.apiToken) + p.install.okta.cmd. + Flag("saml-connector", "SAML connector used for Okta SSO login."). + Required(). + StringVar(&p.install.okta.samlConnector) + p.install.okta.cmd. + Flag("app-id", "Okta ID of the APP used for SSO via SAML"). + StringVar(&p.install.okta.appID) + p.install.okta.cmd. + Flag("scim-token", "Okta SCIM auth token for the plugin to use"). + StringVar(&p.install.okta.scimToken) + p.install.okta.cmd. + Flag("sync-users", "Enable user synchronization"). + Default("true"). + BoolVar(&p.install.okta.userSync) + p.install.okta.cmd. + Flag("owner", "Add default owners for synced Access Lists"). + Short('o'). + StringsVar(&p.install.okta.defaultOwners) + p.install.okta.cmd. + Flag("sync-groups", "Enable group to Access List synchronization"). + Default("true"). + BoolVar(&p.install.okta.accessListSync) + p.install.okta.cmd. + Flag("group", "Add a group filter. Supports globbing by default. Enclose in `^pattern$` for full regex support."). + Short('g'). + StringsVar(&p.install.okta.groupFilters) + p.install.okta.cmd. + Flag("app", "Add an app filter. Supports globbing by default. Enclose in `^pattern$` for full regex support."). + Short('a'). + StringsVar(&p.install.okta.appFilters) +} + +func (p *PluginsCommand) initInstallSCIM(parent *kingpin.CmdClause) { p.install.scim.cmd = p.install.cmd.Command("scim", "Install a new SCIM integration") p.install.scim.cmd. Flag("name", "The name of the SCIM plugin resource to create"). @@ -189,6 +258,142 @@ func (p *PluginsCommand) Cleanup(ctx context.Context, clusterAPI *authclient.Cli return nil } +type samlConnectorsClient interface { + GetSAMLConnector(ctx context.Context, id string, withSecrets bool) (types.SAMLConnector, error) +} + +type pluginsClient interface { + CreatePlugin(ctx context.Context, in *pluginsv1.CreatePluginRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type installPluginArgs struct { + samlConnectors samlConnectorsClient + plugins pluginsClient +} + +func (p *PluginsCommand) InstallOkta(ctx context.Context, args installPluginArgs) error { + log := p.config.Logger.With(logFieldPlugin, p.install.name) + oktaSettings := p.install.okta + + if oktaSettings.accessListSync { + if len(oktaSettings.defaultOwners) == 0 { + return trace.BadParameter("AccessList sync requires at least one default owner to be set") + } + } + + if oktaSettings.scimToken != "" { + if len(oktaSettings.defaultOwners) == 0 { + return trace.BadParameter("SCIM support requires at least one default owner to be set") + } + } + + log.DebugContext(ctx, "Validating SAML Connector...", + logFieldSAMLConnector, oktaSettings.samlConnector) + connector, err := args.samlConnectors.GetSAMLConnector(ctx, oktaSettings.samlConnector, false) + if err != nil { + log.ErrorContext(ctx, "Failed validating SAML connector", + slog.String(logFieldSAMLConnector, oktaSettings.samlConnector), + logErrorMessage(err)) + return trace.Wrap(err) + } + + if p.install.okta.appID == "" { + log.DebugContext(ctx, "Deducing Okta App ID from SAML Connector...", + logFieldSAMLConnector, oktaSettings.samlConnector) + appID, ok := connector.GetMetadata().Labels[types.OktaAppIDLabel] + if ok { + p.install.okta.appID = appID + } + } + + if oktaSettings.scimToken != "" && oktaSettings.appID == "" { + log.ErrorContext(ctx, "SCIM support requires App ID, which was not supplied and couldn't be deduced from the SAML connector") + log.ErrorContext(ctx, "Specify the App ID explicitly with --app-id") + return trace.BadParameter("SCIM support requires app-id to be set") + } + + creds := []*types.PluginStaticCredentialsV1{ + { + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: p.install.name, + Labels: map[string]string{ + types.OktaCredPurposeLabel: types.OktaCredPurposeAuth, + }, + }, + }, + Spec: &types.PluginStaticCredentialsSpecV1{ + Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{ + APIToken: oktaSettings.apiToken, + }, + }, + }, + } + + if oktaSettings.scimToken != "" { + scimTokenHash, err := bcrypt.GenerateFromPassword([]byte(oktaSettings.scimToken), bcrypt.DefaultCost) + if err != nil { + return trace.Wrap(err) + } + + creds = append(creds, &types.PluginStaticCredentialsV1{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: p.install.name + "-scim-token", + Labels: map[string]string{ + types.OktaCredPurposeLabel: types.OktaCredPurposeSCIMToken, + }, + }, + }, + Spec: &types.PluginStaticCredentialsSpecV1{ + Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{ + APIToken: string(scimTokenHash), + }, + }, + }) + } + + req := &pluginsv1.CreatePluginRequest{ + Plugin: &types.PluginV1{ + SubKind: types.PluginSubkindAccess, + Metadata: types.Metadata{ + Labels: map[string]string{ + types.HostedPluginLabel: "true", + }, + Name: p.install.name, + }, + Spec: types.PluginSpecV1{ + Settings: &types.PluginSpecV1_Okta{ + Okta: &types.PluginOktaSettings{ + OrgUrl: oktaSettings.org.String(), + SyncSettings: &types.PluginOktaSyncSettings{ + SsoConnectorId: oktaSettings.samlConnector, + AppId: oktaSettings.appID, + SyncUsers: oktaSettings.userSync, + SyncAccessLists: oktaSettings.accessListSync, + DefaultOwners: oktaSettings.defaultOwners, + GroupFilters: oktaSettings.groupFilters, + AppFilters: oktaSettings.appFilters, + }, + }, + }, + }, + }, + StaticCredentialsList: creds, + CredentialLabels: map[string]string{ + types.OktaOrgURLLabel: oktaSettings.org.String(), + }, + } + + if _, err := args.plugins.CreatePlugin(ctx, req); err != nil { + log.ErrorContext(ctx, "Plugin creation failed", logErrorMessage(err)) + return trace.Wrap(err) + } + + fmt.Println("See https://goteleport.com/docs/application-access/okta/hosted-guide for help configuring provisioning in Okta") + return nil +} + // InstallSCIM implements `tctl plugins install scim`, installing a SCIM integration // plugin into the teleport cluster func (p *PluginsCommand) InstallSCIM(ctx context.Context, client *authclient.Client) error { @@ -197,8 +402,7 @@ func (p *PluginsCommand) InstallSCIM(ctx context.Context, client *authclient.Cli log.DebugContext(ctx, "Fetching cluster info...") info, err := client.Ping(ctx) if err != nil { - log.ErrorContext(ctx, "Failed fetching cluster info", logErrorMessage(err)) - return trace.Wrap(err) + return trace.Wrap(err, "failed fetching cluster info") } scimBaseURL := fmt.Sprintf("https://%s/v1/webapi/scim/%s", info.ProxyPublicAddr, p.install.name) @@ -212,21 +416,16 @@ func (p *PluginsCommand) InstallSCIM(ctx context.Context, client *authclient.Cli log.DebugContext(ctx, "Validating SAML Connector...", logFieldSAMLConnector, connectorID) connector, err := client.GetSAMLConnector(ctx, p.install.scim.samlConnector, false) if err != nil { - log.ErrorContext(ctx, "Failed validating SAML connector", - slog.String(logFieldSAMLConnector, connectorID), - logErrorMessage(err)) - if !p.install.scim.force { - return trace.Wrap(err) + return trace.Wrap(err, "failed validating SAML connector") } } role := p.install.scim.role log.DebugContext(ctx, "Validating Default Role...", logFieldRole, role) if _, err := client.GetRole(ctx, role); err != nil { - log.ErrorContext(ctx, "Failed validating role", slog.String(logFieldRole, role), logErrorMessage(err)) if !p.install.scim.force { - return trace.Wrap(err) + return trace.Wrap(err, "failed validating role") } } @@ -288,6 +487,9 @@ func (p *PluginsCommand) TryRun(ctx context.Context, cmd string, client *authcli switch cmd { case p.cleanupCmd.FullCommand(): err = p.Cleanup(ctx, client) + case p.install.okta.cmd.FullCommand(): + args := installPluginArgs{samlConnectors: client, plugins: client.PluginsClient()} + err = p.InstallOkta(ctx, args) case p.install.scim.cmd.FullCommand(): err = p.InstallSCIM(ctx, client) case p.delete.cmd.FullCommand(): diff --git a/tool/tctl/common/plugins_command_test.go b/tool/tctl/common/plugins_command_test.go new file mode 100644 index 0000000000000..273bb30584bee --- /dev/null +++ b/tool/tctl/common/plugins_command_test.go @@ -0,0 +1,387 @@ +// 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 . + +package common + +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" + + pluginsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1" + "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 + 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"}, + }, + }, + }, + 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", + }, + }, + }, + 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, + 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, + defaultOwners: []string{"admin"}, + groupFilters: []string{"group-alpha", "group-beta"}, + appFilters: []string{"app-gamma", "app-delta", "app-epsilon"}, + }, + }, + }, + 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, + }, + } + + 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 + } + + if testCase.expectSAMLConnectorQuery != "" { + samlConnectorsClient := &mockSAMLConnectorsClient{} + t.Cleanup(func() { samlConnectorsClient.AssertExpectations(t) }) + + samlConnectorsClient. + On("GetSAMLConnector", anyContext, testCase.expectSAMLConnectorQuery, false). + Return(&types.SAMLConnectorV2{}, nil) + + args.samlConnectors = samlConnectorsClient + } + + 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) +} + +type mockSAMLConnectorsClient struct { + mock.Mock +} + +func (m *mockSAMLConnectorsClient) 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) +} + +// anyContext is an argument matcher for testify mocks that matches any context. +var anyContext any = mock.MatchedBy(func(context.Context) bool { return true })