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))
+ }
+ })
+ }
+}