diff --git a/api/constants/constants.go b/api/constants/constants.go index 564a6c6135d42..c91517a6839c6 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -458,3 +458,46 @@ const ( // Multiple decisions can be sent for the same request if the policy requires it. FileTransferDecision string = "file-transfer-decision@goteleport.com" ) + +// Terraform provider environment variable names. +// This is mainly used by the Terraform provider and the `tctl terraform` command. +const ( + // EnvVarTerraformAddress is the environment variable configuring the Teleport address the Terraform provider connects to. + EnvVarTerraformAddress = "TF_TELEPORT_ADDR" + // EnvVarTerraformCertificates is the environment variable configuring the path the Terraform provider loads its + // client certificates from. This only works for direct auth joining. + EnvVarTerraformCertificates = "TF_TELEPORT_CERT" + // EnvVarTerraformCertificatesBase64 is the environment variable configuring the client certificates used by the + // Terraform provider. This only works for direct auth joining. + EnvVarTerraformCertificatesBase64 = "TF_TELEPORT_CERT_BASE64" + // EnvVarTerraformKey is the environment variable configuring the path the Terraform provider loads its + // client key from. This only works for direct auth joining. + EnvVarTerraformKey = "TF_TELEPORT_KEY" + // EnvVarTerraformKeyBase64 is the environment variable configuring the client key used by the + // Terraform provider. This only works for direct auth joining. + EnvVarTerraformKeyBase64 = "TF_TELEPORT_KEY_BASE64" + // EnvVarTerraformRootCertificates is the environment variable configuring the path the Terraform provider loads its + // trusted CA certificates from. This only works for direct auth joining. + EnvVarTerraformRootCertificates = "TF_TELEPORT_ROOT_CA" + // EnvVarTerraformRootCertificatesBase64 is the environment variable configuring the CA certificates trusted by the + // Terraform provider. This only works for direct auth joining. + EnvVarTerraformRootCertificatesBase64 = "TF_TELEPORT_CA_BASE64" + // EnvVarTerraformProfileName is the environment variable containing name of the profile used by the Terraform provider. + EnvVarTerraformProfileName = "TF_TELEPORT_PROFILE_NAME" + // EnvVarTerraformProfilePath is the environment variable containing the profile directory used by the Terraform provider. + EnvVarTerraformProfilePath = "TF_TELEPORT_PROFILE_PATH" + // EnvVarTerraformIdentityFilePath is the environment variable containing the path to the identity file used by the provider. + EnvVarTerraformIdentityFilePath = "TF_TELEPORT_IDENTITY_FILE_PATH" + // EnvVarTerraformIdentityFile is the environment variable containing the identity file used by the Terraform provider. + EnvVarTerraformIdentityFile = "TF_TELEPORT_IDENTITY_FILE" + // EnvVarTerraformIdentityFileBase64 is the environment variable containing the base64-encoded identity file used by the Terraform provider. + EnvVarTerraformIdentityFileBase64 = "TF_TELEPORT_IDENTITY_FILE_BASE64" + // EnvVarTerraformRetryBaseDuration is the environment variable configuring the base duration between two Terraform provider retries. + EnvVarTerraformRetryBaseDuration = "TF_TELEPORT_RETRY_BASE_DURATION" + // EnvVarTerraformRetryCapDuration is the environment variable configuring the maximum duration between two Terraform provider retries. + EnvVarTerraformRetryCapDuration = "TF_TELEPORT_RETRY_CAP_DURATION" + // EnvVarTerraformRetryMaxTries is the environment variable configuring the maximum number of Terraform provider retries. + EnvVarTerraformRetryMaxTries = "TF_TELEPORT_RETRY_MAX_TRIES" + // EnvVarTerraformDialTimeoutDuration is the environment variable configuring the Terraform provider dial timeout. + EnvVarTerraformDialTimeoutDuration = "TF_TELEPORT_DIAL_TIMEOUT_DURATION" +) diff --git a/constants.go b/constants.go index fe3b19147053f..8947756ade0f3 100644 --- a/constants.go +++ b/constants.go @@ -671,6 +671,10 @@ const ( // resources. PresetRequireTrustedDeviceRoleName = "require-trusted-device" + // PresetTerraformProviderRoleName is a name of a default role that allows the Terraform provider + // to configure all its supported Teleport resources. + PresetTerraformProviderRoleName = "terraform-provider" + // SystemAutomaticAccessApprovalRoleName names a preset role that may // automatically approve any Role Access Request SystemAutomaticAccessApprovalRoleName = "@teleport-access-approver" diff --git a/integration/helpers/helpers.go b/integration/helpers/helpers.go index 63525f86b319f..9c9bd8e231ff3 100644 --- a/integration/helpers/helpers.go +++ b/integration/helpers/helpers.go @@ -189,7 +189,7 @@ func CloseAgent(teleAgent *teleagent.AgentServer, socketDirPath string) error { return nil } -func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, ttl time.Duration) string { +func MustCreateUserKey(t *testing.T, tc *TeleInstance, username string, ttl time.Duration) *client.Key { key, err := client.GenerateRSAKey() require.NoError(t, err) key.ClusterName = tc.Secrets.SiteName @@ -209,9 +209,14 @@ func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, hostCAs, err := tc.Process.GetAuthServer().GetCertAuthorities(context.Background(), types.HostCA, false) require.NoError(t, err) key.TrustedCerts = authclient.AuthoritiesToTrustedCerts(hostCAs) + return key +} + +func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, ttl time.Duration) string { + key := MustCreateUserKey(t, tc, username, ttl) idPath := filepath.Join(t.TempDir(), "user_identity") - _, err = identityfile.Write(context.Background(), identityfile.WriteConfig{ + _, err := identityfile.Write(context.Background(), identityfile.WriteConfig{ OutputPath: idPath, Key: key, Format: identityfile.FormatFile, diff --git a/integration/tctl_terraform_env_test.go b/integration/tctl_terraform_env_test.go new file mode 100644 index 0000000000000..9ebea1c95bc35 --- /dev/null +++ b/integration/tctl_terraform_env_test.go @@ -0,0 +1,349 @@ +/* + * 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 integration + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/breaker" + "github.com/gravitational/teleport/api/client" + "github.com/gravitational/teleport/api/client/webclient" + "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/tctl/common" +) + +// TestTCTLTerraformCommand_ProxyJoin validates that the command `tctl terraform env` can run against a Teleport Proxy +// service and generates valid credentials Terraform can use to connect to Teleport. +func TestTCTLTerraformCommand_ProxyJoin(t *testing.T) { + testDir := t.TempDir() + + // Test setup: creating a teleport instance running auth and proxy + clusterName := "root.example.com" + cfg := helpers.InstanceConfig{ + ClusterName: clusterName, + HostID: uuid.New().String(), + NodeName: helpers.Loopback, + Log: utils.NewLoggerForTests(), + } + cfg.Listeners = helpers.SingleProxyPortSetup(t, &cfg.Fds) + rc := helpers.NewInstance(t, cfg) + + rcConf := servicecfg.MakeDefaultConfig() + rcConf.DataDir = filepath.Join(testDir, "data") + rcConf.Auth.Enabled = true + rcConf.Proxy.Enabled = true + rcConf.SSH.Enabled = false + rcConf.Proxy.DisableWebInterface = true + rcConf.Version = "v3" + rcConf.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex) + + testUsername := "test-user" + createTCTLTerraformUserAndRole(t, testUsername, rc) + + // Test setup: starting the Teleport instance + err := rc.CreateEx(t, nil, rcConf) + require.NoError(t, err) + + err = rc.Start() + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, rc.StopAll()) + }) + + // Test setup: obtaining and authclient connected via the proxy + clt := getAuthClientForProxy(t, rc, testUsername, time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _, err = clt.Ping(ctx) + require.NoError(t, err) + + addr, err := rc.Process.ProxyWebAddr() + require.NoError(t, err) + + // Test execution, running the tctl command + tctlCfg := &servicecfg.Config{} + err = tctlCfg.SetAuthServerAddresses([]utils.NetAddr{*addr}) + require.NoError(t, err) + tctlCommand := common.TerraformCommand{} + + app := kingpin.New("test", "test") + tctlCommand.Initialize(app, tctlCfg) + _, err = app.Parse([]string{"terraform", "env"}) + require.NoError(t, err) + // Create io buffer writer + stdout := &bytes.Buffer{} + + err = tctlCommand.RunEnvCommand(ctx, clt, stdout, os.Stderr) + require.NoError(t, err) + + vars := parseExportedEnvVars(t, stdout) + require.Contains(t, vars, constants.EnvVarTerraformAddress) + require.Contains(t, vars, constants.EnvVarTerraformIdentityFileBase64) + + // Test validation: connect with the credentials in env vars and do a ping + require.Equal(t, addr.String(), vars[constants.EnvVarTerraformAddress]) + + connectWithCredentialsFromVars(t, vars, clt) +} + +// TestTCTLTerraformCommand_AuthJoin validates that the command `tctl terraform env` can run against a Teleport Auth +// service and generates valid credentials Terraform can use to connect to Teleport. +func TestTCTLTerraformCommand_AuthJoin(t *testing.T) { + t.Parallel() + testDir := t.TempDir() + + // Test setup: creating a teleport instance running auth and proxy + clusterName := "root.example.com" + cfg := helpers.InstanceConfig{ + ClusterName: clusterName, + HostID: uuid.New().String(), + NodeName: helpers.Loopback, + Log: utils.NewLoggerForTests(), + } + cfg.Listeners = helpers.SingleProxyPortSetup(t, &cfg.Fds) + rc := helpers.NewInstance(t, cfg) + + rcConf := servicecfg.MakeDefaultConfig() + rcConf.DataDir = filepath.Join(testDir, "data") + rcConf.Auth.Enabled = true + rcConf.Proxy.Enabled = false + rcConf.SSH.Enabled = false + rcConf.Version = "v3" + + testUsername := "test-user" + createTCTLTerraformUserAndRole(t, testUsername, rc) + + // Test setup: starting the Teleport instance + err := rc.CreateEx(t, nil, rcConf) + require.NoError(t, err) + + err = rc.Start() + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, rc.StopAll()) + }) + + // Test setup: obtaining and authclient connected via the proxy + clt := getAuthClientForAuth(t, rc, testUsername, time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _, err = clt.Ping(ctx) + require.NoError(t, err) + + addr, err := rc.Process.AuthAddr() + require.NoError(t, err) + + // Test execution, running the tctl command + tctlCfg := &servicecfg.Config{} + err = tctlCfg.SetAuthServerAddresses([]utils.NetAddr{*addr}) + require.NoError(t, err) + tctlCommand := common.TerraformCommand{} + + app := kingpin.New("test", "test") + tctlCommand.Initialize(app, tctlCfg) + _, err = app.Parse([]string{"terraform", "env"}) + require.NoError(t, err) + // Create io buffer writer + stdout := &bytes.Buffer{} + + err = tctlCommand.RunEnvCommand(ctx, clt, stdout, os.Stderr) + require.NoError(t, err) + + vars := parseExportedEnvVars(t, stdout) + require.Contains(t, vars, constants.EnvVarTerraformAddress) + require.Contains(t, vars, constants.EnvVarTerraformIdentityFileBase64) + + // Test validation: connect with the credentials in env vars and do a ping + require.Equal(t, addr.String(), vars[constants.EnvVarTerraformAddress]) + + connectWithCredentialsFromVars(t, vars, clt) +} + +func createTCTLTerraformUserAndRole(t *testing.T, username string, instance *helpers.TeleInstance) { + // Test setup: creating a test user and its role + role, err := types.NewRole("test-role", types.RoleSpecV6{ + Options: types.RoleOptions{}, + Allow: types.RoleConditions{ + Rules: []types.Rule{ + { + Resources: []string{types.KindToken, types.KindRole, types.KindBot}, + Verbs: []string{types.VerbRead, types.VerbCreate, types.VerbList, types.VerbUpdate}, + }, + }, + }, + }) + require.NoError(t, err) + + instance.AddUserWithRole(username, role) +} + +// getAuthCLientForProxy builds an authclient.CLient connecting to the auth through the proxy +// (with a web client resolver hitting /v1/wenapi/ping and a tunnel auth dialer reaching the auth through the proxy). +// For the tests, the client is configured to trust the proxy TLS certs on first connection. +func getAuthClientForProxy(t *testing.T, tc *helpers.TeleInstance, username string, ttl time.Duration) *authclient.Client { + // Get TLS and SSH material + key := helpers.MustCreateUserKey(t, tc, username, ttl) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + tlsConfig, err := key.TeleportClientTLSConfig(nil, []string{tc.Config.Auth.ClusterName.GetClusterName()}) + require.NoError(t, err) + tlsConfig.InsecureSkipVerify = true + proxyAddr, err := tc.Process.ProxyWebAddr() + require.NoError(t, err) + sshConfig, err := key.ProxyClientSSHConfig(proxyAddr.Host()) + require.NoError(t, err) + + // Build auth client configuration + authAddr, err := tc.Process.AuthAddr() + require.NoError(t, err) + clientConfig := &authclient.Config{ + TLS: tlsConfig, + SSH: sshConfig, + AuthServers: []utils.NetAddr{*authAddr}, + Log: utils.NewLoggerForTests(), + CircuitBreakerConfig: breaker.Config{}, + DialTimeout: 0, + DialOpts: nil, + // Insecure: true, + ProxyDialer: nil, + } + + // Configure the resolver and dialer to connect to the auth via a proxy + resolver, err := reversetunnelclient.CachingResolver( + ctx, + reversetunnelclient.WebClientResolver(&webclient.Config{ + Context: ctx, + ProxyAddr: clientConfig.AuthServers[0].String(), + Insecure: clientConfig.Insecure, + Timeout: clientConfig.DialTimeout, + }), + nil /* clock */) + require.NoError(t, err) + + dialer, err := reversetunnelclient.NewTunnelAuthDialer(reversetunnelclient.TunnelAuthDialerConfig{ + Resolver: resolver, + ClientConfig: clientConfig.SSH, + Log: clientConfig.Log, + InsecureSkipTLSVerify: clientConfig.Insecure, + ClusterCAs: clientConfig.TLS.RootCAs, + }) + require.NoError(t, err) + + clientConfig.ProxyDialer = dialer + + // Finally, build a client and connect + clt, err := authclient.Connect(ctx, clientConfig) + require.NoError(t, err) + return clt +} + +// getAuthClientForAuth builds an authclient.CLient connecting to the auth directly. +// This client only has TLSConfig set (as opposed to TLSConfig+SSHConfig). +func getAuthClientForAuth(t *testing.T, tc *helpers.TeleInstance, username string, ttl time.Duration) *authclient.Client { + // Get TLS and SSH material + key := helpers.MustCreateUserKey(t, tc, username, ttl) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + tlsConfig, err := key.TeleportClientTLSConfig(nil, []string{tc.Config.Auth.ClusterName.GetClusterName()}) + require.NoError(t, err) + + // Build auth client configuration + authAddr, err := tc.Process.AuthAddr() + require.NoError(t, err) + clientConfig := &authclient.Config{ + TLS: tlsConfig, + AuthServers: []utils.NetAddr{*authAddr}, + Log: utils.NewLoggerForTests(), + CircuitBreakerConfig: breaker.Config{}, + DialTimeout: 0, + DialOpts: nil, + ProxyDialer: nil, + } + + // Build the client and connect + clt, err := authclient.Connect(ctx, clientConfig) + require.NoError(t, err) + return clt +} + +// parseExportedEnvVars parses a buffer corresponding to the program's stdout and returns a map {env: value} +// of the exported variables. The buffer content should looks like: +// +// export VAR1="VALUE1" +// export VAR2="VALUE2" +// # this is a comment +func parseExportedEnvVars(t *testing.T, stdout *bytes.Buffer) map[string]string { + // Test validation: parse the output and extract exported envs + vars := map[string]string{} + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line[0] == '#' { + continue + } + require.True(t, strings.HasPrefix(line, "export ")) + parts := strings.Split(line, "=") + env := strings.TrimSpace(parts[0][7:]) + value := strings.Trim(strings.Join(parts[1:], "="), `"' `) + require.NotEmpty(t, env) + require.NotEmpty(t, value) + vars[env] = value + } + return vars +} + +// connectWithCredentialsFromVars takes the environment variables exported by the `tctl terraform env` command, +// builds a Teleport client from them, and validates it can ping the cluster. +func connectWithCredentialsFromVars(t *testing.T, vars map[string]string, clt *authclient.Client) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + identity, err := base64.StdEncoding.DecodeString(vars[constants.EnvVarTerraformIdentityFileBase64]) + require.NoError(t, err) + creds := client.LoadIdentityFileFromString(string(identity)) + require.NotNil(t, creds) + botClt, err := client.New(ctx, client.Config{ + Addrs: []string{vars[constants.EnvVarTerraformAddress]}, + Credentials: []client.Credentials{creds}, + InsecureAddressDiscovery: clt.Config().InsecureSkipVerify, + Context: ctx, + }) + require.NoError(t, err) + _, err = botClt.Ping(ctx) + require.NoError(t, err) +} diff --git a/integrations/terraform/Makefile b/integrations/terraform/Makefile index d835003c1afc9..d691d4e17ab5a 100644 --- a/integrations/terraform/Makefile +++ b/integrations/terraform/Makefile @@ -8,6 +8,7 @@ TFDIR ?= example ADDFLAGS ?= BUILDFLAGS ?= $(ADDFLAGS) -ldflags '-w -s' +# CGO must NOT be enabled as hashicorp cloud does not support running providers using on CGO. CGOFLAG ?= CGO_ENABLED=0 RELEASE = terraform-provider-teleport-v$(VERSION)-$(OS)-$(ARCH)-bin diff --git a/integrations/terraform/provider/provider.go b/integrations/terraform/provider/provider.go index 1abb2a5d9a751..df2d1eeead643 100644 --- a/integrations/terraform/provider/provider.go +++ b/integrations/terraform/provider/provider.go @@ -38,6 +38,7 @@ import ( "google.golang.org/grpc/grpclog" "github.com/gravitational/teleport/api/client" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/lib/utils" ) @@ -220,22 +221,22 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq return } - addr := p.stringFromConfigOrEnv(config.Addr, "TF_TELEPORT_ADDR", "") - certPath := p.stringFromConfigOrEnv(config.CertPath, "TF_TELEPORT_CERT", "") - certBase64 := p.stringFromConfigOrEnv(config.CertBase64, "TF_TELEPORT_CERT_BASE64", "") - keyPath := p.stringFromConfigOrEnv(config.KeyPath, "TF_TELEPORT_KEY", "") - keyBase64 := p.stringFromConfigOrEnv(config.KeyBase64, "TF_TELEPORT_KEY_BASE64", "") - caPath := p.stringFromConfigOrEnv(config.RootCaPath, "TF_TELEPORT_ROOT_CA", "") - caBase64 := p.stringFromConfigOrEnv(config.RootCaBase64, "TF_TELEPORT_CA_BASE64", "") - profileName := p.stringFromConfigOrEnv(config.ProfileName, "TF_TELEPORT_PROFILE_NAME", "") - profileDir := p.stringFromConfigOrEnv(config.ProfileDir, "TF_TELEPORT_PROFILE_PATH", "") - identityFilePath := p.stringFromConfigOrEnv(config.IdentityFilePath, "TF_TELEPORT_IDENTITY_FILE_PATH", "") - identityFile := p.stringFromConfigOrEnv(config.IdentityFile, "TF_TELEPORT_IDENTITY_FILE", "") - identityFileBase64 := p.stringFromConfigOrEnv(config.IdentityFileBase64, "TF_TELEPORT_IDENTITY_FILE_BASE64", "") - retryBaseDurationStr := p.stringFromConfigOrEnv(config.RetryBaseDuration, "TF_TELEPORT_RETRY_BASE_DURATION", "1s") - retryCapDurationStr := p.stringFromConfigOrEnv(config.RetryCapDuration, "TF_TELEPORT_RETRY_CAP_DURATION", "5s") - maxTriesStr := p.stringFromConfigOrEnv(config.RetryMaxTries, "TF_TELEPORT_RETRY_MAX_TRIES", "10") - dialTimeoutDurationStr := p.stringFromConfigOrEnv(config.DialTimeoutDuration, "TF_TELEPORT_DIAL_TIMEOUT_DURATION", "30s") + addr := p.stringFromConfigOrEnv(config.Addr, constants.EnvVarTerraformAddress, "") + certPath := p.stringFromConfigOrEnv(config.CertPath, constants.EnvVarTerraformCertificates, "") + certBase64 := p.stringFromConfigOrEnv(config.CertBase64, constants.EnvVarTerraformCertificatesBase64, "") + keyPath := p.stringFromConfigOrEnv(config.KeyPath, constants.EnvVarTerraformKey, "") + keyBase64 := p.stringFromConfigOrEnv(config.KeyBase64, constants.EnvVarTerraformKeyBase64, "") + caPath := p.stringFromConfigOrEnv(config.RootCaPath, constants.EnvVarTerraformRootCertificates, "") + caBase64 := p.stringFromConfigOrEnv(config.RootCaBase64, constants.EnvVarTerraformRootCertificatesBase64, "") + profileName := p.stringFromConfigOrEnv(config.ProfileName, constants.EnvVarTerraformProfileName, "") + profileDir := p.stringFromConfigOrEnv(config.ProfileDir, constants.EnvVarTerraformProfilePath, "") + identityFilePath := p.stringFromConfigOrEnv(config.IdentityFilePath, constants.EnvVarTerraformIdentityFilePath, "") + identityFile := p.stringFromConfigOrEnv(config.IdentityFile, constants.EnvVarTerraformIdentityFile, "") + identityFileBase64 := p.stringFromConfigOrEnv(config.IdentityFileBase64, constants.EnvVarTerraformIdentityFileBase64, "") + retryBaseDurationStr := p.stringFromConfigOrEnv(config.RetryBaseDuration, constants.EnvVarTerraformRetryBaseDuration, "1s") + retryCapDurationStr := p.stringFromConfigOrEnv(config.RetryCapDuration, constants.EnvVarTerraformRetryCapDuration, "5s") + maxTriesStr := p.stringFromConfigOrEnv(config.RetryMaxTries, constants.EnvVarTerraformRetryMaxTries, "10") + dialTimeoutDurationStr := p.stringFromConfigOrEnv(config.DialTimeoutDuration, constants.EnvVarTerraformDialTimeoutDuration, "30s") if !p.validateAddr(addr, resp) { return diff --git a/lib/auth/init.go b/lib/auth/init.go index 64cf79285a71b..3bccc80edf5d9 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -935,6 +935,7 @@ func GetPresetRoles() []types.Role { services.NewPresetRequireTrustedDeviceRole(), services.NewSystemOktaAccessRole(), services.NewSystemOktaRequesterRole(), + services.NewPresetTerraformProviderRole(), } // Certain `New$FooRole()` functions will return a nil role if the diff --git a/lib/auth/init_test.go b/lib/auth/init_test.go index a988f6577e30d..e51ded4636edd 100644 --- a/lib/auth/init_test.go +++ b/lib/auth/init_test.go @@ -497,6 +497,7 @@ func TestPresets(t *testing.T) { teleport.PresetEditorRoleName, teleport.PresetAccessRoleName, teleport.PresetAuditorRoleName, + teleport.PresetTerraformProviderRoleName, } t.Run("EmptyCluster", func(t *testing.T) { diff --git a/lib/services/presets.go b/lib/services/presets.go index 1ca83f0d6377a..0d0318fdb0dd9 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" + apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/modules" ) @@ -553,6 +554,61 @@ func NewSystemOktaRequesterRole() types.Role { return role } +// NewPresetTerraformProviderRole returns a new pre-defined role for the Teleport Terraform provider. +// This role can edit any Terraform-supported resource. +func NewPresetTerraformProviderRole() types.Role { + role := &types.RoleV6{ + Kind: types.KindRole, + Version: types.V7, + Metadata: types.Metadata{ + Name: teleport.PresetTerraformProviderRoleName, + Namespace: apidefaults.Namespace, + Description: "Default Terraform provider role", + Labels: map[string]string{ + types.TeleportInternalResourceType: types.PresetResource, + }, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + // In Teleport, you can only see what you have access to. To be able to reconcile + // Apps, Databases, and Nodes, Terraform must be able to access them all. + // For Databases and Nodes, Terraform cannot actually access them because it has no + // Login/user set. + AppLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}}, + DatabaseLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}}, + NodeLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}}, + // Every resource currently supported by the Terraform provider. + Rules: []types.Rule{ + { + Resources: []string{ + types.KindAccessList, + types.KindApp, + types.KindClusterAuthPreference, + types.KindClusterMaintenanceConfig, + types.KindClusterNetworkingConfig, + types.KindDatabase, + types.KindDevice, + types.KindGithub, + types.KindLoginRule, + types.KindNode, + types.KindOIDC, + types.KindOktaImportRule, + types.KindRole, + types.KindSAML, + types.KindSessionRecordingConfig, + types.KindToken, + types.KindTrustedCluster, + types.KindUser, + }, + Verbs: RW(), + }, + }, + }, + }, + } + return role +} + // bootstrapRoleMetadataLabels are metadata labels that will be applied to each role. // These are intended to add labels for older roles that didn't previously have them. func bootstrapRoleMetadataLabels() map[string]map[string]string { @@ -597,11 +653,17 @@ func defaultAllowRules() map[string][]types.Rule { // - DatabaseServiceLabels (db_service_labels) // - GroupLabels func defaultAllowLabels(enterprise bool) map[string]types.RoleConditions { + wildcardLabels := types.Labels{types.Wildcard: []string{types.Wildcard}} conditions := map[string]types.RoleConditions{ teleport.PresetAccessRoleName: { - DatabaseServiceLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + DatabaseServiceLabels: wildcardLabels, DatabaseRoles: []string{teleport.TraitInternalDBRolesVariable}, }, + teleport.PresetTerraformProviderRoleName: { + AppLabels: wildcardLabels, + DatabaseLabels: wildcardLabels, + NodeLabels: wildcardLabels, + }, } if enterprise { @@ -729,15 +791,21 @@ func AddRoleDefaults(role types.Role) (types.Role, error) { if ok { for _, kind := range []string{ types.KindApp, + types.KindDatabase, types.KindDatabaseService, + types.KindNode, types.KindUserGroup, } { var labels types.Labels switch kind { case types.KindApp: labels = defaultLabels.AppLabels + case types.KindDatabase: + labels = defaultLabels.DatabaseLabels case types.KindDatabaseService: labels = defaultLabels.DatabaseServiceLabels + case types.KindNode: + labels = defaultLabels.NodeLabels case types.KindUserGroup: labels = defaultLabels.GroupLabels } diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go index 9a78c4191d28f..a8b5dd9bf14c1 100644 --- a/tool/tctl/common/cmds.go +++ b/tool/tctl/common/cmds.go @@ -62,5 +62,6 @@ func Commands() []CLICommand { &fido2Command{}, &webauthnwinCommand{}, &touchIDCommand{}, + &TerraformCommand{}, } } diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 24af9c1da1a24..eacd36f91c12b 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -196,6 +196,8 @@ func TryRun(commands []CLICommand, args []string) error { cfg.TeleportHome = filepath.Clean(cfg.TeleportHome) } + cfg.Debug = ccf.Debug + // configure all commands with Teleport configuration (they share 'cfg') clientConfig, err := ApplyConfig(&ccf, cfg) if err != nil { diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go new file mode 100644 index 0000000000000..798c4de0b9997 --- /dev/null +++ b/tool/tctl/common/terraform_command.go @@ -0,0 +1,391 @@ +/* + * 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" + "encoding/base64" + "errors" + "fmt" + "io" + "log/slog" + "os" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/constants" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/api/identityfile" + "github.com/gravitational/teleport/api/mfa" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/common" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/tbot" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/identity" + "github.com/gravitational/teleport/lib/tbot/ssh" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" +) + +const ( + terraformHelperDefaultResourcePrefix = "tctl-terraform-env-" + terraformHelperDefaultTTL = "1h" + + // importantText is the ANSI escape sequence used to make the terminal text bold. + importantText = "\033[1;31m" + // resetText is the ANSI escape sequence used to reset the terminal text style. + resetText = "\033[0m" +) + +var terraformEnvCommandLabels = map[string]string{ + common.TeleportNamespace + "/" + "created-by": "tctl-terraform-env", +} + +// TerraformCommand is a tctl command providing helpers for users to run the Terraform provider. +type TerraformCommand struct { + resourcePrefix string + existingRole string + botTTL time.Duration + + cfg *servicecfg.Config + + envCmd *kingpin.CmdClause + + // envOutput is where we write the `export env=value`, its value is os.Stdout when run via tctl, a custom buffer in tests. + envOutput io.Writer + // envOutput is where we write the progress updates, its value is os.Stderr run via tctl. + userOutput io.Writer + + log *slog.Logger +} + +// Initialize sets up the "tctl bots" command. +func (c *TerraformCommand) Initialize(app *kingpin.Application, cfg *servicecfg.Config) { + tfCmd := app.Command("terraform", "Helpers to run the Teleport Terraform Provider.") + + c.envCmd = tfCmd.Command("env", "Obtain certificates and load them into environments variables. This creates a temporary MachineID bot.") + c.envCmd.Flag( + "resource-prefix", + fmt.Sprintf("Resource prefix to use when creating the Terraform role and bots. Defaults to [%s]", terraformHelperDefaultResourcePrefix), + ).Default(terraformHelperDefaultResourcePrefix).StringVar(&c.resourcePrefix) + c.envCmd.Flag( + "bot-ttl", + fmt.Sprintf("Time-to-live of the Bot resource. The bot will be removed after this period. Defaults to [%s]", terraformHelperDefaultTTL), + ).Default(terraformHelperDefaultTTL).DurationVar(&c.botTTL) + c.envCmd.Flag( + "role", + fmt.Sprintf("Role used by Terraform. The role must already exist in Teleport. When not specified, uses the default role %q", teleport.PresetTerraformProviderRoleName), + ).StringVar(&c.existingRole) + + // Save a pointer to the config to be able to recover the Debug config later + c.cfg = cfg +} + +// TryRun attempts to run subcommands. +func (c *TerraformCommand) TryRun(ctx context.Context, cmd string, client *authclient.Client) (match bool, err error) { + switch cmd { + case c.envCmd.FullCommand(): + err = c.RunEnvCommand(ctx, client, os.Stdout, os.Stderr) + default: + return false, nil + } + + return true, trace.Wrap(err) +} + +// RunEnvCommand contains all the Terraform helper logic. It: +// - passes the MFA Challenge +// - creates the Terraform role +// - creates a temporary Terraform bot +// - uses the bot to obtain certificates for Terraform +// - exports certificates and Terraform configuration in environment variables +// envOutput and userOutput parameters are respectively stdout and stderr, +// except during tests where we want to catch the command output. +func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient.Client, envOutput, userOutput io.Writer) error { + // If we're not actively debugging, suppress any kind of logging from other teleport components + if !c.cfg.Debug { + utils.InitLogger(utils.LoggingForCLI, slog.LevelError) + } + c.envOutput = envOutput + c.userOutput = userOutput + c.log = slog.Default() + + // Validate that the bot expires + if c.botTTL == 0 { + return trace.BadParameter("--bot-ttl must be greater than zero") + } + + addrs := c.cfg.AuthServerAddresses() + if len(addrs) == 0 { + return trace.BadParameter("no auth server addresses found") + } + addr := addrs[0] + + // Prompt for admin action MFA if required, allowing reuse for UpsertRole, UpsertToken and CreateBot. + c.showProgress("🔑 Detecting if MFA is required") + mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/) + if err == nil { + ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse) + } else if !errors.Is(err, &mfa.ErrMFANotRequired) && !errors.Is(err, &mfa.ErrMFANotSupported) { + return trace.Wrap(err) + } + + // Checking Terraform role + roleName, err := c.checkIfRoleExists(ctx, client) + if err != nil { + switch { + case trace.IsNotFound(err) && c.existingRole == "": + return trace.Wrap(err, `The Terraform role %q does not exist in your Teleport cluster. +This default role is included in Teleport clusters whose version is higher than v16.1 or v17. +If you want to use "tctl terraform env" against an older Teleport cluster, you must create the Terraform role +yourself and set the flag --role .`, roleName) + case trace.IsNotFound(err) && c.existingRole != "": + return trace.Wrap(err, `The Terraform role %q specified with --role does not exist in your Teleport cluster. +Please check that the role exists in the cluster.`, roleName) + case trace.IsAccessDenied(err): + return trace.Wrap(err, `Failed to validate if the role %q exists. +To use the "tctl terraform env" command you must have rights to list and read Teleport roles. +If you got a role granted recently, you might have to run "tsh logout" and login again.`, roleName) + default: + return trace.Wrap(err, "Unexpected error while trying to validate if the role %q exists.", roleName) + } + } + + // Create temporary bot and token + tokenName, err := c.createTransientBotAndToken(ctx, client, roleName) + if trace.IsAccessDenied(err) { + return trace.Wrap(err, `Failed to create the temporary Terraform bot or its token. +To use the "tctl terraform env" command you must have rights to create Teleport bot and token resources. +If you got a role granted recently, you might have to run "tsh logout" and login again.`) + } + if err != nil { + return trace.Wrap(err, "bootstrapping bot") + } + + // Now run tbot + c.showProgress("🤖 Using the temporary bot to obtain certificates") + id, sshHostCACerts, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client) + if err != nil { + return trace.Wrap(err, "The temporary bot failed to connect to Teleport.") + } + + envVars, err := identityToTerraformEnvVars(addr.String(), id, sshHostCACerts) + if err != nil { + return trace.Wrap(err, "exporting identity into environment variables") + } + + // Export environment variables + c.showProgress(fmt.Sprintf("🚀 Certificates obtained, you can now use Terraform in this terminal for %s", c.botTTL.String())) + for env, value := range envVars { + fmt.Fprintf(c.envOutput, "export %s=%q\n", env, value) + } + fmt.Fprintln(c.envOutput, "#") + fmt.Fprintf(c.envOutput, "# %sYou must invoke this command in an eval: eval $(tctl terraform env)%s\n", importantText, resetText) + return nil +} + +// createTransientBotAndToken creates a Bot resource and a secret Token. +// The token is single use (secret tokens are consumed on MachineID join) +// and the bot expires after the given TTL. +func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, client *authclient.Client, roleName string) (string, error) { + // Create token and bot name + suffix, err := utils.CryptoRandomHex(4) + if err != nil { + return "", trace.Wrap(err) + } + + botName := c.resourcePrefix + suffix + c.showProgress(fmt.Sprintf("⚙️ Creating temporary bot %q and its token", botName)) + + // Generate a token + tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return "", trace.Wrap(err, "generating random token") + } + tokenSpec := types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + JoinMethod: types.JoinMethodToken, + BotName: botName, + } + // Token should be consumed on bot join in a few seconds. If the bot fails to join for any reason, + // the token should not outlive the bot. + token, err := types.NewProvisionTokenFromSpec(tokenName, time.Now().Add(c.botTTL), tokenSpec) + if err != nil { + return "", trace.Wrap(err) + } + token.SetLabels(terraformEnvCommandLabels) + if err := client.UpsertToken(ctx, token); err != nil { + return "", trace.Wrap(err, "upserting token") + } + + // Create bot + bot := &machineidv1pb.Bot{ + Metadata: &headerv1.Metadata{ + Name: botName, + Expires: timestamppb.New(time.Now().Add(c.botTTL)), + Labels: terraformEnvCommandLabels, + }, + Spec: &machineidv1pb.BotSpec{ + Roles: []string{roleName}, + }, + } + + _, err = client.BotServiceClient().CreateBot(ctx, &machineidv1pb.CreateBotRequest{ + Bot: bot, + }) + if err != nil { + return "", trace.Wrap(err, "creating bot") + } + return tokenName, nil +} + +// roleClient describes the minimal set of operations that the helper uses to +// create the Terraform provider role. +type roleClient interface { + UpsertRole(context.Context, types.Role) (types.Role, error) + GetRole(context.Context, string) (types.Role, error) +} + +// createRoleIfNeeded checks if the terraform role exists. +// Returns the Terraform role name even in case of error, so this can be used to craft nice error messages. +func (c *TerraformCommand) checkIfRoleExists(ctx context.Context, client roleClient) (string, error) { + roleName := c.existingRole + + if roleName == "" { + roleName = teleport.PresetTerraformProviderRoleName + } + _, err := client.GetRole(ctx, roleName) + + return roleName, trace.Wrap(err) +} + +// useBotToObtainIdentity takes secret bot token and runs a one-shot in-process tbot to trade the token +// against valid certificates. Those certs are then serialized into an identity file. +// The output is a set of environment variables, one of them including the base64-encoded identity file. +// Later, the Terraform provider will read those environment variables to build its Teleport client. +// Note: the function also returns the SSH Host CA cert encoded in the known host format. +// The identity.Identity uses a different format (authorized keys). +func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client) (*identity.Identity, [][]byte, error) { + credential := &config.UnstableClientCredentialOutput{} + cfg := &config.BotConfig{ + Version: "", + Onboarding: config.OnboardingConfig{ + TokenValue: token, + JoinMethod: types.JoinMethodToken, + }, + Storage: &config.StorageConfig{Destination: &config.DestinationMemory{}}, + Services: config.ServiceConfigs{credential}, + CertificateTTL: c.botTTL, + Oneshot: true, + // If --insecure is passed, the bot will trust the certificate on first use. + // This does not truly disable TLS validation, only trusts the certificate on first connection. + Insecure: clt.Config().InsecureSkipVerify, + } + + // When invoked only with auth address, tbot will try both joining as an auth and as a proxy. + // This allows us to not care about how the user connects to Teleport (auth vs proxy joining). + cfg.AuthServer = addr.String() + + // Insecure joining is not compatible with CA pinning + if !cfg.Insecure { + // We use the client to get the TLS CA and compute its fingerprint. + // In case of auth joining, this ensures that tbot connects to the same Teleport auth as we do + // (no man in the middle possible between when we build the auth client and when we run tbot). + localCAResponse, err := clt.GetClusterCACert(ctx) + if err != nil { + return nil, nil, trace.Wrap(err, "getting cluster CA certificate") + } + caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA) + if err != nil { + return nil, nil, trace.Wrap(err, "calculating CA pins") + } + cfg.Onboarding.CAPins = caPins + } + + err := cfg.CheckAndSetDefaults() + if err != nil { + return nil, nil, trace.Wrap(err, "checking the bot's configuration") + } + + // Run the bot + bot := tbot.New(cfg, c.log) + err = bot.Run(ctx) + if err != nil { + return nil, nil, trace.Wrap(err, "running the bot") + } + + // Retrieve the credentials obtained by tbot. + facade, err := credential.Facade() + if err != nil { + return nil, nil, trace.Wrap(err, "accessing credentials") + } + + id := facade.Get() + + clusterName, err := clt.GetClusterName() + if err != nil { + return nil, nil, trace.Wrap(err, "retrieving cluster name") + } + knownHosts, err := ssh.GenerateKnownHosts(ctx, clt, []string{clusterName.GetClusterName()}, addr.Host()) + if err != nil { + return nil, nil, trace.Wrap(err, "retrieving SSH Host CA") + } + sshHostCACerts := [][]byte{[]byte(knownHosts)} + + return id, sshHostCACerts, nil +} + +// showProgress sends status update messages ot the user. +func (c *TerraformCommand) showProgress(update string) { + _, _ = fmt.Fprintln(c.userOutput, update) +} + +// identityToTerraformEnvVars takes an identity and builds environment variables +// configuring the Terraform provider to use this identity. +// The sshHostCACerts must be in the "known hosts" format. +func identityToTerraformEnvVars(addr string, id *identity.Identity, sshHostCACerts [][]byte) (map[string]string, error) { + idFile := &identityfile.IdentityFile{ + PrivateKey: id.PrivateKeyBytes, + Certs: identityfile.Certs{ + SSH: id.CertBytes, + TLS: id.TLSCertBytes, + }, + CACerts: identityfile.CACerts{ + SSH: sshHostCACerts, + TLS: id.TLSCACertsBytes, + }, + } + idBytes, err := identityfile.Encode(idFile) + if err != nil { + return nil, trace.Wrap(err) + } + idBase64 := base64.StdEncoding.EncodeToString(idBytes) + return map[string]string{ + constants.EnvVarTerraformAddress: addr, + constants.EnvVarTerraformIdentityFileBase64: idBase64, + }, nil +} diff --git a/tool/tctl/common/terraform_command_test.go b/tool/tctl/common/terraform_command_test.go new file mode 100644 index 0000000000000..e1db99aa73347 --- /dev/null +++ b/tool/tctl/common/terraform_command_test.go @@ -0,0 +1,113 @@ +/* + * 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" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/testing/integration" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" +) + +// Note: due to its complex interactions with Teleport, the `tctl terraform env` +// command is mainly not tested via unit tests but by integration tests validating the full flow. +// You can find its integration tests in `integration/tctl_terraform_env_test.go` + +func TestTerraformCommand_checkIfRoleExists(t *testing.T) { + // Test setup + authHelper := integration.MinimalAuthHelper{} + adminClient := authHelper.StartServer(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + newRoleFixture := func(name string) types.Role { + role := services.NewPresetTerraformProviderRole() + role.SetName(name) + return role + } + + tests := []struct { + name string + // Test setup + existingRoleFlag string + fixture types.Role + // Test validation + expectedRoleName string + expectedErr require.ErrorAssertionFunc + }{ + { + name: "Succeeds if preset role is found", + existingRoleFlag: "", + fixture: newRoleFixture(teleport.PresetTerraformProviderRoleName), + expectedRoleName: teleport.PresetTerraformProviderRoleName, + expectedErr: require.NoError, + }, + { + name: "Fails if preset role is not found", + existingRoleFlag: "", + expectedRoleName: teleport.PresetTerraformProviderRoleName, + expectedErr: require.Error, + }, + { + name: "Succeeds if custom existing role is specified and exists", + existingRoleFlag: "existing-role", + fixture: newRoleFixture("existing-role"), + expectedRoleName: "existing-role", + expectedErr: require.NoError, + }, + { + name: "Fails if custom existing role is specified and does not exist", + existingRoleFlag: "existing-role", + expectedRoleName: "existing-role", + expectedErr: require.Error, + }, + } + for _, tt := range tests { + // Warning: Those tests cannot be run in parallel + t.Run(tt.name, func(t *testing.T) { + // Test case setup + if tt.fixture != nil { + _, err := adminClient.CreateRole(ctx, tt.fixture) + require.NoError(t, err) + } + + // Test execution + c := &TerraformCommand{ + existingRole: tt.existingRoleFlag, + userOutput: os.Stderr, + log: utils.NewSlogLoggerForTests(), + } + roleName, err := c.checkIfRoleExists(ctx, adminClient) + tt.expectedErr(t, err) + require.Equal(t, tt.expectedRoleName, roleName) + + // Test cleanup + if tt.fixture != nil { + require.NoError(t, adminClient.DeleteRole(ctx, roleName)) + } + }) + } +}