From 2a582cdb9ff2391eb47bd006cf507965941c5e36 Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Wed, 19 Jun 2024 09:52:09 -0400 Subject: [PATCH 01/12] Introduce the `tctl terrafor env` command --- integration/helpers/helpers.go | 9 +- integration/tctl_terraform_env_test.go | 331 ++++++++++++++++++ integrations/terraform/Makefile | 2 +- tool/tctl/common/cmds.go | 1 + tool/tctl/common/tctl.go | 2 + tool/tctl/common/terraform_command.go | 380 +++++++++++++++++++++ tool/tctl/common/terraform_command_test.go | 121 +++++++ 7 files changed, 843 insertions(+), 3 deletions(-) create mode 100644 integration/tctl_terraform_env_test.go create mode 100644 tool/tctl/common/terraform_command.go create mode 100644 tool/tctl/common/terraform_command_test.go 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..430be4b112c83 --- /dev/null +++ b/integration/tctl_terraform_env_test.go @@ -0,0 +1,331 @@ +package integration + +import ( + "bufio" + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/google/uuid" + "github.com/segmentio/asm/base64" + "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/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) { + 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 = 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, common.EnvVarTerraformAddress) + require.Contains(t, vars, "TF_TELEPORT_IDENTITY_FILE_BASE64") + + // Test validation: connect with the credentials in env vars and do a ping + require.Equal(t, addr.String(), vars[common.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, common.EnvVarTerraformAddress) + require.Contains(t, vars, "TF_TELEPORT_IDENTITY_FILE_BASE64") + + // Test validation: connect with the credentials in env vars and do a ping + require.Equal(t, addr.String(), vars[common.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[common.EnvVarTerraformIdentity]) + require.NoError(t, err) + creds := client.LoadIdentityFileFromString(string(identity)) + require.NotNil(t, creds) + botClt, err := client.New(ctx, client.Config{ + Addrs: []string{vars[common.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 3f7a4b8960f11..84b4cc7691125 100644 --- a/integrations/terraform/Makefile +++ b/integrations/terraform/Makefile @@ -8,7 +8,7 @@ TFDIR ?= example ADDFLAGS ?= BUILDFLAGS ?= $(ADDFLAGS) -ldflags '-w -s' -CGOFLAG ?= CGO_ENABLED=0 +CGOFLAG ?= CGO_ENABLED=1 RELEASE = terraform-provider-teleport-v$(VERSION)-$(OS)-$(ARCH)-bin 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..cf75649ccd9fe --- /dev/null +++ b/tool/tctl/common/terraform_command.go @@ -0,0 +1,380 @@ +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" + + 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" + apiutils "github.com/gravitational/teleport/api/utils" + "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 ( + // EnvVarTerraformAddress is the environment variable configuring the Teleport address the Terraform provider connects to. + EnvVarTerraformAddress = "TF_TELEPORT_ADDR" + // EnvVarTerraformIdentity is the environment variable configuring the Teleport identity the Terraform provider uses. + EnvVarTerraformIdentity = "TF_TELEPORT_IDENTITY_FILE_BASE64" +) + +const ( + terraformHelperDefaultResourcePrefix = "terraform-env-" + terraformHelperDefaultTTL = "1h" +) + +var terraformRoleSpec = types.RoleSpecV6{ + Allow: types.RoleConditions{ + 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}}, + Rules: []types.Rule{ + { + Resources: []string{ + types.KindUser, types.KindRole, types.KindToken, types.KindTrustedCluster, types.KindGithub, + types.KindOIDC, types.KindSAML, types.KindClusterAuthPreference, types.KindClusterNetworkingConfig, + types.KindClusterMaintenanceConfig, types.KindSessionRecordingConfig, types.KindApp, + types.KindDatabase, types.KindLoginRule, types.KindDevice, types.KindOktaImportRule, + types.KindAccessList, types.KindNode, + }, + Verbs: []string{types.VerbList, types.VerbCreate, types.VerbRead, types.VerbUpdate, types.VerbDelete}, + }, + }, + }, +} + +// 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 +} + +// 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", "Resource prefix to use when creating the Terraform role and bots.").Default(terraformHelperDefaultResourcePrefix).StringVar(&c.resourcePrefix) + c.envCmd.Flag("bot-ttl", "Time-to-live of the Bot resource. The bot will be removed after this period.").Default(terraformHelperDefaultTTL).DurationVar(&c.botTTL) + c.envCmd.Flag("use-existing-role", "Existing Terraform role to use instead of creating a new one.").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 + + // 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) + } + + // Upsert Terraform role + roleName, err := c.createRoleIfNeeded(ctx, client) + if err != nil { + return trace.Wrap(err) + } + + // Create temporary bot and token + tokenName, err := c.createTransientBotAndToken(ctx, client, roleName) + if err != nil { + return trace.Wrap(err, "bootstrapping bot") + } + + // Now run tbot + c.showProgress("Using the temporary bot to obtain certificates 🤖") + id, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client) + if err != nil { + return trace.Wrap(err, "obtaining identity") + } + + envVars, err := identityToTerraformEnvVars(addr.String(), id) + if err != nil { + return trace.Wrap(err, "exporting identity into environment variables") + } + + // Export environment variables + c.showProgress("Certificates obtained, you can now use Terraform in this terminal 🚀") + for env, value := range envVars { + _, _ = fmt.Fprintf(c.envOutput, "export %s=%q\n", env, value) + } + fmt.Println("# You must invoke this command in an eval: eval $(tctl terraform-helper)") + 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)) + + roles := []string{roleName} + var token types.ProvisionToken + + // 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) + } + 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)), + }, + Spec: &machineidv1pb.BotSpec{ + Roles: roles, + }, + } + + bot, 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 upserts the Terraform role, or checks if the role exists. +// Returns the Terraform role name. +func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleClient) (string, error) { + log := slog.Default() + roleName := c.existingRole + + // Create role if --use-existing-role is not set + if roleName == "" { + roleName = c.resourcePrefix + "provider" + log.InfoContext(ctx, "Creating/Updating the Terraform Provider role", "role", roleName) + role, err := types.NewRole(roleName, terraformRoleSpec) + if err != nil { + return "", trace.Wrap(err) + } + _, err = client.UpsertRole(ctx, role) + if err != nil { + return "", trace.Wrap(err, "upserting role") + } + c.showProgress(fmt.Sprintf("Created Terraform Provider role: %q", roleName)) + } else { + // Else we check if the provided role exists + _, err := client.GetRole(ctx, roleName) + if trace.IsNotFound(err) { + log.ErrorContext(ctx, "Role not found", "role", roleName) + return "", trace.Wrap(err) + } else if err != nil { + return "", trace.Wrap(err, "getting role") + } + + log.InfoContext(ctx, "Using existing Terraform role", "role", roleName) + c.showProgress(fmt.Sprintf("Using existing Terraform Provider role: %q", roleName)) + } + return roleName, nil +} + +// 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. +func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client) (*identity.Identity, error) { + credential := &config.UnstableClientCredentialOutput{} + cfg := &config.BotConfig{ + Version: "", + Onboarding: config.OnboardingConfig{ + TokenValue: token, + JoinMethod: types.JoinMethodToken, + }, + Storage: &config.StorageConfig{Destination: &config.DestinationMemory{}}, + Outputs: []config.Output{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, trace.Wrap(err, "getting cluster CA certificate") + } + caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA) + if err != nil { + return nil, trace.Wrap(err, "calculating CA pins") + } + cfg.Onboarding.CAPins = caPins + } + + err := cfg.CheckAndSetDefaults() + if err != nil { + return nil, trace.Wrap(err, "checking the bot's configuration") + } + + // Run the bot + bot := tbot.New(cfg, slog.Default()) + err = bot.Run(ctx) + if err != nil { + return nil, trace.Wrap(err, "running the bot") + } + + // Retrieve the credentials obtained by tbot. + facade, err := credential.Facade() + if err != nil { + return nil, trace.Wrap(err, "accessing credentials") + } + + id := facade.Get() + + // Workaround for https://github.com/gravitational/teleport-private/issues/1572 + clusterName, err := clt.GetClusterName() + if err != nil { + return nil, trace.Wrap(err, "retrieving cluster name") + } + knownHosts, err := ssh.GenerateKnownHosts(ctx, clt, []string{clusterName.GetClusterName()}, addr.Host()) + if err != nil { + return nil, trace.Wrap(err, "retrieving SSH Host CA") + } + id.SSHCACertBytes = [][]byte{ + []byte(knownHosts), + } + // End of workaround + + return id, 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. +func identityToTerraformEnvVars(addr string, id *identity.Identity) (map[string]string, error) { + idFile := &identityfile.IdentityFile{ + PrivateKey: id.PrivateKeyBytes, + Certs: identityfile.Certs{ + SSH: id.CertBytes, + TLS: id.TLSCertBytes, + }, + CACerts: identityfile.CACerts{ + SSH: id.SSHCACertBytes, + 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{ + EnvVarTerraformAddress: addr, + EnvVarTerraformIdentity: 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..b0d32386121f3 --- /dev/null +++ b/tool/tctl/common/terraform_command_test.go @@ -0,0 +1,121 @@ +package common + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/testing/integration" +) + +// 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_createRoleIfNeeded(t *testing.T) { + // Test setup + authHelper := integration.MinimalAuthHelper{} + adminClient := authHelper.StartServer(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Setting labels allows us to check whether the role + // has been updated by the helper or not. + defaultLabels := terraformRoleSpec.Allow.AppLabels + testLabels := types.Labels{"foo": []string{"bar"}} + existingRoleSpec := terraformRoleSpec + existingRoleSpec.Allow.AppLabels = testLabels + + newRoleFixture := func(t *testing.T, name string) types.Role { + role, err := types.NewRole(name, existingRoleSpec) + require.NoError(t, err) + return role + } + + tests := []struct { + name string + // Test setup + resourcePrefixFlag string + existingRoleFlag string + fixture types.Role + // Test validation + wantErr require.ErrorAssertionFunc + expectedRoleName string + expectedRoleAppLabels types.Labels + }{ + { + name: "Create role when not exist", + wantErr: require.NoError, + expectedRoleAppLabels: defaultLabels, + expectedRoleName: terraformHelperDefaultResourcePrefix + "provider", + }, + { + name: "Update existing role", + fixture: newRoleFixture(t, terraformHelperDefaultResourcePrefix+"provider"), + wantErr: require.NoError, + expectedRoleAppLabels: defaultLabels, + expectedRoleName: terraformHelperDefaultResourcePrefix + "provider", + }, + { + name: "Honour resource prefix", + resourcePrefixFlag: "test-", + wantErr: require.NoError, + expectedRoleName: "test-provider", + expectedRoleAppLabels: defaultLabels, + }, + { + name: "Does not change existing role", + existingRoleFlag: "existing-role", + fixture: newRoleFixture(t, "existing-role"), + wantErr: require.NoError, + expectedRoleName: "existing-role", + expectedRoleAppLabels: testLabels, + }, + { + name: "Fails if existing role is not found", + existingRoleFlag: "existing-role", + wantErr: 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) + } + // mimick the kingpin default behaviour + resourcePrefix := tt.resourcePrefixFlag + if resourcePrefix == "" { + resourcePrefix = terraformHelperDefaultResourcePrefix + } + + // Test execution + c := &TerraformCommand{ + resourcePrefix: resourcePrefix, + existingRole: tt.existingRoleFlag, + } + roleName, err := c.createRoleIfNeeded(ctx, adminClient) + tt.wantErr(t, err) + require.Equal(t, tt.expectedRoleName, roleName) + if tt.expectedRoleAppLabels != nil { + gotRole, err := adminClient.GetRole(ctx, roleName) + require.NoError(t, err) + require.Empty(t, cmp.Diff(tt.expectedRoleAppLabels, gotRole.GetAppLabels(types.Allow))) + } + + // Test cleanup + if roleName != "" { + err = adminClient.DeleteRole(ctx, roleName) + if !trace.IsNotFound(err) { + require.NoError(t, err) + } + } + }) + } +} From 927849f1efa9bec1ea2bae5c9473b169876d6118 Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Fri, 28 Jun 2024 16:30:18 -0400 Subject: [PATCH 02/12] fix tests --- integration/tctl_terraform_env_test.go | 1 - tool/tctl/common/terraform_command_test.go | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/tctl_terraform_env_test.go b/integration/tctl_terraform_env_test.go index 430be4b112c83..2ed43a4b07557 100644 --- a/integration/tctl_terraform_env_test.go +++ b/integration/tctl_terraform_env_test.go @@ -31,7 +31,6 @@ import ( // 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) { - t.Parallel() testDir := t.TempDir() // Test setup: creating a teleport instance running auth and proxy diff --git a/tool/tctl/common/terraform_command_test.go b/tool/tctl/common/terraform_command_test.go index b0d32386121f3..f66939976bfb1 100644 --- a/tool/tctl/common/terraform_command_test.go +++ b/tool/tctl/common/terraform_command_test.go @@ -2,6 +2,7 @@ package common import ( "context" + "os" "testing" "github.com/google/go-cmp/cmp" @@ -99,6 +100,7 @@ func TestTerraformCommand_createRoleIfNeeded(t *testing.T) { c := &TerraformCommand{ resourcePrefix: resourcePrefix, existingRole: tt.existingRoleFlag, + userOutput: os.Stderr, } roleName, err := c.createRoleIfNeeded(ctx, adminClient) tt.wantErr(t, err) From 3b4daa6290a02bad9e6ff9f005012b21a4ebeedc Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Tue, 2 Jul 2024 15:59:10 -0400 Subject: [PATCH 03/12] address marco's feedback + use correct b64 lib --- api/constants/constants.go | 43 ++++++++ integration/tctl_terraform_env_test.go | 19 ++-- integrations/terraform/Makefile | 3 +- integrations/terraform/provider/provider.go | 33 +++--- tool/tctl/common/terraform_command.go | 112 +++++++++++++------- 5 files changed, 143 insertions(+), 67 deletions(-) diff --git a/api/constants/constants.go b/api/constants/constants.go index 564a6c6135d42..646ec81918529 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_BAS64" + // 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/integration/tctl_terraform_env_test.go b/integration/tctl_terraform_env_test.go index 2ed43a4b07557..645564c423117 100644 --- a/integration/tctl_terraform_env_test.go +++ b/integration/tctl_terraform_env_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "os" "path/filepath" "strings" @@ -12,13 +13,13 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/google/uuid" - "github.com/segmentio/asm/base64" "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" @@ -93,11 +94,11 @@ func TestTCTLTerraformCommand_ProxyJoin(t *testing.T) { require.NoError(t, err) vars := parseExportedEnvVars(t, stdout) - require.Contains(t, vars, common.EnvVarTerraformAddress) - require.Contains(t, vars, "TF_TELEPORT_IDENTITY_FILE_BASE64") + 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[common.EnvVarTerraformAddress]) + require.Equal(t, addr.String(), vars[constants.EnvVarTerraformAddress]) connectWithCredentialsFromVars(t, vars, clt) } @@ -166,11 +167,11 @@ func TestTCTLTerraformCommand_AuthJoin(t *testing.T) { require.NoError(t, err) vars := parseExportedEnvVars(t, stdout) - require.Contains(t, vars, common.EnvVarTerraformAddress) - require.Contains(t, vars, "TF_TELEPORT_IDENTITY_FILE_BASE64") + 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[common.EnvVarTerraformAddress]) + require.Equal(t, addr.String(), vars[constants.EnvVarTerraformAddress]) connectWithCredentialsFromVars(t, vars, clt) } @@ -314,12 +315,12 @@ func connectWithCredentialsFromVars(t *testing.T, vars map[string]string, clt *a ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - identity, err := base64.StdEncoding.DecodeString(vars[common.EnvVarTerraformIdentity]) + 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[common.EnvVarTerraformAddress]}, + Addrs: []string{vars[constants.EnvVarTerraformAddress]}, Credentials: []client.Credentials{creds}, InsecureAddressDiscovery: clt.Config().InsecureSkipVerify, Context: ctx, diff --git a/integrations/terraform/Makefile b/integrations/terraform/Makefile index 84b4cc7691125..c70707c0433be 100644 --- a/integrations/terraform/Makefile +++ b/integrations/terraform/Makefile @@ -8,7 +8,8 @@ TFDIR ?= example ADDFLAGS ?= BUILDFLAGS ?= $(ADDFLAGS) -ldflags '-w -s' -CGOFLAG ?= CGO_ENABLED=1 +# 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 600a0ec0a4131..47ef2ff857516 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/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index cf75649ccd9fe..177eee840e05e 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -14,6 +14,7 @@ import ( "github.com/gravitational/trace" "google.golang.org/protobuf/types/known/timestamppb" + "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" @@ -31,13 +32,6 @@ import ( "github.com/gravitational/teleport/lib/utils" ) -const ( - // EnvVarTerraformAddress is the environment variable configuring the Teleport address the Terraform provider connects to. - EnvVarTerraformAddress = "TF_TELEPORT_ADDR" - // EnvVarTerraformIdentity is the environment variable configuring the Teleport identity the Terraform provider uses. - EnvVarTerraformIdentity = "TF_TELEPORT_IDENTITY_FILE_BASE64" -) - const ( terraformHelperDefaultResourcePrefix = "terraform-env-" terraformHelperDefaultTTL = "1h" @@ -51,11 +45,24 @@ var terraformRoleSpec = types.RoleSpecV6{ Rules: []types.Rule{ { Resources: []string{ - types.KindUser, types.KindRole, types.KindToken, types.KindTrustedCluster, types.KindGithub, - types.KindOIDC, types.KindSAML, types.KindClusterAuthPreference, types.KindClusterNetworkingConfig, - types.KindClusterMaintenanceConfig, types.KindSessionRecordingConfig, types.KindApp, - types.KindDatabase, types.KindLoginRule, types.KindDevice, types.KindOktaImportRule, - types.KindAccessList, types.KindNode, + 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: []string{types.VerbList, types.VerbCreate, types.VerbRead, types.VerbUpdate, types.VerbDelete}, }, @@ -84,9 +91,18 @@ func (c *TerraformCommand) Initialize(app *kingpin.Application, cfg *servicecfg. 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", "Resource prefix to use when creating the Terraform role and bots.").Default(terraformHelperDefaultResourcePrefix).StringVar(&c.resourcePrefix) - c.envCmd.Flag("bot-ttl", "Time-to-live of the Bot resource. The bot will be removed after this period.").Default(terraformHelperDefaultTTL).DurationVar(&c.botTTL) - c.envCmd.Flag("use-existing-role", "Existing Terraform role to use instead of creating a new one.").StringVar(&c.existingRole) + 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( + "use-existing-role", + "Existing Terraform role to use instead of creating a new one.", + ).StringVar(&c.existingRole) // Save a pointer to the config to be able to recover the Debug config later c.cfg = cfg @@ -119,6 +135,7 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient } c.envOutput = envOutput c.userOutput = userOutput + log := slog.Default() // Validate that the bot expires if c.botTTL == 0 { @@ -141,22 +158,33 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient } // Upsert Terraform role - roleName, err := c.createRoleIfNeeded(ctx, client) + roleName, err := c.createRoleIfNeeded(ctx, client, log) + if trace.IsAccessDenied(err) { + return trace.Wrap(err, `Failed to create/update the Terraform role. +You must have the rights to get/create/update roles to rely on automatic role creation. +If you don't have those rights, you can set the flag --use-existing-role "your-existing-terraform-role". +If you got a role granted recently, you might have to run "tsh logout" and login again.`) + } if err != nil { return trace.Wrap(err) } // 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. +To use the "tctl terraform env" command you must have rights to create Teleport bot 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, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client) + id, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client, log) if err != nil { - return trace.Wrap(err, "obtaining identity") + return trace.Wrap(err, "The temporary bot failed to connect to Teleport.") } envVars, err := identityToTerraformEnvVars(addr.String(), id) @@ -169,7 +197,7 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient for env, value := range envVars { _, _ = fmt.Fprintf(c.envOutput, "export %s=%q\n", env, value) } - fmt.Println("# You must invoke this command in an eval: eval $(tctl terraform-helper)") + fmt.Fprintln(c.envOutput, "# You must invoke this command in an eval: eval $(tctl terraform env)") return nil } @@ -186,7 +214,6 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien botName := c.resourcePrefix + suffix c.showProgress(fmt.Sprintf("Creating temporary bot %q and its token", botName)) - roles := []string{roleName} var token types.ProvisionToken // Generate a token @@ -216,7 +243,7 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien Expires: timestamppb.New(time.Now().Add(c.botTTL)), }, Spec: &machineidv1pb.BotSpec{ - Roles: roles, + Roles: []string{roleName}, }, } @@ -238,24 +265,11 @@ type roleClient interface { // createRoleIfNeeded upserts the Terraform role, or checks if the role exists. // Returns the Terraform role name. -func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleClient) (string, error) { - log := slog.Default() +func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleClient, log *slog.Logger) (string, error) { roleName := c.existingRole - // Create role if --use-existing-role is not set - if roleName == "" { - roleName = c.resourcePrefix + "provider" - log.InfoContext(ctx, "Creating/Updating the Terraform Provider role", "role", roleName) - role, err := types.NewRole(roleName, terraformRoleSpec) - if err != nil { - return "", trace.Wrap(err) - } - _, err = client.UpsertRole(ctx, role) - if err != nil { - return "", trace.Wrap(err, "upserting role") - } - c.showProgress(fmt.Sprintf("Created Terraform Provider role: %q", roleName)) - } else { + // If roleName is specified, we don't attempt to create the role but we still check that it exists. + if roleName != "" { // Else we check if the provided role exists _, err := client.GetRole(ctx, roleName) if trace.IsNotFound(err) { @@ -267,7 +281,23 @@ func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleCl log.InfoContext(ctx, "Using existing Terraform role", "role", roleName) c.showProgress(fmt.Sprintf("Using existing Terraform Provider role: %q", roleName)) + return roleName, nil + } + + roleName = c.resourcePrefix + "provider" + log.InfoContext(ctx, "Creating/Updating the Terraform Provider role", "role", roleName) + + role, err := types.NewRole(roleName, terraformRoleSpec) + if err != nil { + return "", trace.Wrap(err) } + + _, err = client.UpsertRole(ctx, role) + if err != nil { + return "", trace.Wrap(err, "upserting role") + } + c.showProgress(fmt.Sprintf("Created Terraform Provider role: %q", roleName)) + return roleName, nil } @@ -275,7 +305,7 @@ func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleCl // 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. -func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client) (*identity.Identity, error) { +func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client, log *slog.Logger) (*identity.Identity, error) { credential := &config.UnstableClientCredentialOutput{} cfg := &config.BotConfig{ Version: "", @@ -318,7 +348,7 @@ func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr util } // Run the bot - bot := tbot.New(cfg, slog.Default()) + bot := tbot.New(cfg, log) err = bot.Run(ctx) if err != nil { return nil, trace.Wrap(err, "running the bot") @@ -374,7 +404,7 @@ func identityToTerraformEnvVars(addr string, id *identity.Identity) (map[string] } idBase64 := base64.StdEncoding.EncodeToString(idBytes) return map[string]string{ - EnvVarTerraformAddress: addr, - EnvVarTerraformIdentity: idBase64, + constants.EnvVarTerraformAddress: addr, + constants.EnvVarTerraformIdentityFileBase64: idBase64, }, nil } From 247cfb1ff2fede0ac81ce263d6f4bf86f502bbf6 Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Tue, 2 Jul 2024 15:59:53 -0400 Subject: [PATCH 04/12] add license --- integration/tctl_terraform_env_test.go | 18 ++++++++++++++++++ tool/tctl/common/terraform_command.go | 18 ++++++++++++++++++ tool/tctl/common/terraform_command_test.go | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/integration/tctl_terraform_env_test.go b/integration/tctl_terraform_env_test.go index 645564c423117..9ebea1c95bc35 100644 --- a/integration/tctl_terraform_env_test.go +++ b/integration/tctl_terraform_env_test.go @@ -1,3 +1,21 @@ +/* + * 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 ( diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index 177eee840e05e..ab39634063ec5 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -1,3 +1,21 @@ +/* + * 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 ( diff --git a/tool/tctl/common/terraform_command_test.go b/tool/tctl/common/terraform_command_test.go index f66939976bfb1..e97ef17795b52 100644 --- a/tool/tctl/common/terraform_command_test.go +++ b/tool/tctl/common/terraform_command_test.go @@ -1,3 +1,21 @@ +/* + * 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 ( From e07762f426053dcc6a590f4a4824b3e1df22740d Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Tue, 2 Jul 2024 16:29:36 -0400 Subject: [PATCH 05/12] add created-by label as specified in the RFD --- tool/tctl/common/terraform_command.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index ab39634063ec5..90cb94df09eb5 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -23,6 +23,7 @@ import ( "encoding/base64" "errors" "fmt" + "github.com/gravitational/teleport/api/types/common" "io" "log/slog" "os" @@ -88,6 +89,10 @@ var terraformRoleSpec = types.RoleSpecV6{ }, } +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 @@ -250,6 +255,7 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien if err != nil { return "", trace.Wrap(err) } + token.SetLabels(terraformEnvCommandLabels) if err := client.UpsertToken(ctx, token); err != nil { return "", trace.Wrap(err, "upserting token") } @@ -259,6 +265,7 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien Metadata: &headerv1.Metadata{ Name: botName, Expires: timestamppb.New(time.Now().Add(c.botTTL)), + Labels: terraformEnvCommandLabels, }, Spec: &machineidv1pb.BotSpec{ Roles: []string{roleName}, @@ -288,7 +295,6 @@ func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleCl // If roleName is specified, we don't attempt to create the role but we still check that it exists. if roleName != "" { - // Else we check if the provided role exists _, err := client.GetRole(ctx, roleName) if trace.IsNotFound(err) { log.ErrorContext(ctx, "Role not found", "role", roleName) @@ -309,6 +315,7 @@ func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleCl if err != nil { return "", trace.Wrap(err) } + role.SetStaticLabels(terraformEnvCommandLabels) _, err = client.UpsertRole(ctx, role) if err != nil { From 638cb8f024a469e54bd440fe2774232289f7516d Mon Sep 17 00:00:00 2001 From: Hugo Shaka Date: Tue, 2 Jul 2024 21:59:40 -0400 Subject: [PATCH 06/12] Update tool/tctl/common/terraform_command.go Co-authored-by: Roman Tkachenko --- tool/tctl/common/terraform_command.go | 1 - 1 file changed, 1 deletion(-) diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index 90cb94df09eb5..61ab85f530dae 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -387,7 +387,6 @@ func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr util id := facade.Get() - // Workaround for https://github.com/gravitational/teleport-private/issues/1572 clusterName, err := clt.GetClusterName() if err != nil { return nil, trace.Wrap(err, "retrieving cluster name") From 36a23f5ac4a9298072b07a5901deb56fe8896dbe Mon Sep 17 00:00:00 2001 From: Hugo Shaka Date: Tue, 2 Jul 2024 22:01:41 -0400 Subject: [PATCH 07/12] Apply suggestions from code review Co-authored-by: Roman Tkachenko --- api/constants/constants.go | 2 +- tool/tctl/common/terraform_command.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/constants/constants.go b/api/constants/constants.go index 646ec81918529..c91517a6839c6 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -481,7 +481,7 @@ const ( 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_BAS64" + 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. diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index 61ab85f530dae..3bcd9b4a4a64f 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -218,7 +218,7 @@ If you got a role granted recently, you might have to run "tsh logout" and login // Export environment variables c.showProgress("Certificates obtained, you can now use Terraform in this terminal 🚀") for env, value := range envVars { - _, _ = fmt.Fprintf(c.envOutput, "export %s=%q\n", env, value) + fmt.Fprintf(c.envOutput, "export %s=%q\n", env, value) } fmt.Fprintln(c.envOutput, "# You must invoke this command in an eval: eval $(tctl terraform env)") return nil From 7db1e1781d84ec578e029b894b7438209c568ef8 Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Wed, 3 Jul 2024 12:27:40 -0400 Subject: [PATCH 08/12] Have telpeort create the Terraform default role --- constants.go | 3 + lib/auth/init.go | 1 + lib/services/presets.go | 56 +++++++++ rfd/0173-terraform-machine-id.md | 9 +- tool/tctl/common/terraform_command.go | 135 +++++++-------------- tool/tctl/common/terraform_command_test.go | 98 ++++++--------- 6 files changed, 147 insertions(+), 155 deletions(-) diff --git a/constants.go b/constants.go index 7fa1cb7162119..9603b5810c553 100644 --- a/constants.go +++ b/constants.go @@ -693,6 +693,9 @@ const ( // access to Okta resources. This will be used by the Okta requester role to // search for Okta resources. SystemOktaAccessRoleName = "okta-access" + // PresetTerraformProviderRoleName is a name of a default role that allows the Terraform provider + // to configure all its supported Teleport resources. + PresetTerraformProviderRoleName = "terraform-provider-all-resources" ) var PresetRoles = []string{PresetEditorRoleName, PresetAccessRoleName, PresetAuditorRoleName} diff --git a/lib/auth/init.go b/lib/auth/init.go index 4c69f8bdd1c98..612e9e00cbe3f 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -923,6 +923,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/services/presets.go b/lib/services/presets.go index 0c2ec220bf601..46b1b52d6f921 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -28,6 +28,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.SystemResource, + }, + }, + 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 { diff --git a/rfd/0173-terraform-machine-id.md b/rfd/0173-terraform-machine-id.md index 3372c3f91f6cc..dc8830ac18ac6 100644 --- a/rfd/0173-terraform-machine-id.md +++ b/rfd/0173-terraform-machine-id.md @@ -1,6 +1,6 @@ --- author: hugoShaka (hugo.hervieux@goteleport.com) -state: draft +state: implemented --- # RFD 173 - Authenticating the Terraform provider with MachineID @@ -115,9 +115,12 @@ we used for the Teleport operator. #### Resource bootstrapping +Teleport will now ship with a default Terraform provider role: `terraform-provider-all-resources` automatically updated using +our existing preset logic. This will allow users to benefit from the new resources supported by the Terraform provider +without having to update their preset role. The suffix `-all-resources` is here to minimize the risk of conflict with existing +terraform provider roles. + We will automatically create the resources required by the Terraform provider before executing Terraform: -- the `terraform-provider` role (this is an upsert so we can add new rules after a provider update). This resource does - not expire. - the `terraform-provider-` bot allowed to issue certificates for the `terraform-provider` role. This resource expires by default after 1h. - the `terraform-provider-` secret random provision token allowing to join as diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index 3bcd9b4a4a64f..cfcc822303974 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -23,7 +23,6 @@ import ( "encoding/base64" "errors" "fmt" - "github.com/gravitational/teleport/api/types/common" "io" "log/slog" "os" @@ -33,13 +32,14 @@ import ( "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" - apiutils "github.com/gravitational/teleport/api/utils" + "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" @@ -52,42 +52,14 @@ import ( ) const ( - terraformHelperDefaultResourcePrefix = "terraform-env-" + terraformHelperDefaultResourcePrefix = "tctl-terraform-env-" terraformHelperDefaultTTL = "1h" -) -var terraformRoleSpec = types.RoleSpecV6{ - Allow: types.RoleConditions{ - 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}}, - 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: []string{types.VerbList, types.VerbCreate, types.VerbRead, types.VerbUpdate, types.VerbDelete}, - }, - }, - }, -} + // 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", @@ -107,6 +79,8 @@ type TerraformCommand struct { 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. @@ -158,7 +132,7 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient } c.envOutput = envOutput c.userOutput = userOutput - log := slog.Default() + c.log = slog.Default() // Validate that the bot expires if c.botTTL == 0 { @@ -172,7 +146,7 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient addr := addrs[0] // Prompt for admin action MFA if required, allowing reuse for UpsertRole, UpsertToken and CreateBot. - c.showProgress("Detecting if MFA is required") + c.showProgress("🔑 Detecting if MFA is required") mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/) if err == nil { ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse) @@ -180,16 +154,25 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient return trace.Wrap(err) } - // Upsert Terraform role - roleName, err := c.createRoleIfNeeded(ctx, client, log) - if trace.IsAccessDenied(err) { - return trace.Wrap(err, `Failed to create/update the Terraform role. -You must have the rights to get/create/update roles to rely on automatic role creation. -If you don't have those rights, you can set the flag --use-existing-role "your-existing-terraform-role". -If you got a role granted recently, you might have to run "tsh logout" and login again.`) - } + // Checking Terraform role + roleName, err := c.checkIfRoleExists(ctx, client) if err != nil { - return trace.Wrap(err) + switch { + case trace.IsNotFound(err) && c.existingRole == "": + return trace.Wrap(err, `The Terraform role %q does not exist in your Teleport cluster. +This role is included by default 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 --use-existing-role .`, roleName) + case trace.IsNotFound(err) && c.existingRole != "": + return trace.Wrap(err, `The Terraform role %q specified with --use-existing-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 @@ -204,8 +187,8 @@ If you got a role granted recently, you might have to run "tsh logout" and login } // Now run tbot - c.showProgress("Using the temporary bot to obtain certificates 🤖") - id, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client, log) + c.showProgress("🤖 Using the temporary bot to obtain certificates") + id, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client) if err != nil { return trace.Wrap(err, "The temporary bot failed to connect to Teleport.") } @@ -216,11 +199,12 @@ If you got a role granted recently, you might have to run "tsh logout" and login } // Export environment variables - c.showProgress("Certificates obtained, you can now use Terraform in this terminal 🚀") + 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, "# You must invoke this command in an eval: eval $(tctl terraform env)") + 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 } @@ -235,9 +219,7 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien } botName := c.resourcePrefix + suffix - c.showProgress(fmt.Sprintf("Creating temporary bot %q and its token", botName)) - - var token types.ProvisionToken + c.showProgress(fmt.Sprintf("⚙️ Creating temporary bot %q and its token", botName)) // Generate a token tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) @@ -251,7 +233,7 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien } // 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) + token, err := types.NewProvisionTokenFromSpec(tokenName, time.Now().Add(c.botTTL), tokenSpec) if err != nil { return "", trace.Wrap(err) } @@ -288,49 +270,24 @@ type roleClient interface { GetRole(context.Context, string) (types.Role, error) } -// createRoleIfNeeded upserts the Terraform role, or checks if the role exists. -// Returns the Terraform role name. -func (c *TerraformCommand) createRoleIfNeeded(ctx context.Context, client roleClient, log *slog.Logger) (string, 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 is specified, we don't attempt to create the role but we still check that it exists. - if roleName != "" { - _, err := client.GetRole(ctx, roleName) - if trace.IsNotFound(err) { - log.ErrorContext(ctx, "Role not found", "role", roleName) - return "", trace.Wrap(err) - } else if err != nil { - return "", trace.Wrap(err, "getting role") - } - - log.InfoContext(ctx, "Using existing Terraform role", "role", roleName) - c.showProgress(fmt.Sprintf("Using existing Terraform Provider role: %q", roleName)) - return roleName, nil - } - - roleName = c.resourcePrefix + "provider" - log.InfoContext(ctx, "Creating/Updating the Terraform Provider role", "role", roleName) - - role, err := types.NewRole(roleName, terraformRoleSpec) - if err != nil { - return "", trace.Wrap(err) - } - role.SetStaticLabels(terraformEnvCommandLabels) - - _, err = client.UpsertRole(ctx, role) - if err != nil { - return "", trace.Wrap(err, "upserting role") + if roleName == "" { + roleName = teleport.PresetTerraformProviderRoleName } - c.showProgress(fmt.Sprintf("Created Terraform Provider role: %q", roleName)) + _, err := client.GetRole(ctx, roleName) - return roleName, nil + 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. -func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client, log *slog.Logger) (*identity.Identity, error) { +func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client) (*identity.Identity, error) { credential := &config.UnstableClientCredentialOutput{} cfg := &config.BotConfig{ Version: "", @@ -373,7 +330,7 @@ func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr util } // Run the bot - bot := tbot.New(cfg, log) + bot := tbot.New(cfg, c.log) err = bot.Run(ctx) if err != nil { return nil, trace.Wrap(err, "running the bot") diff --git a/tool/tctl/common/terraform_command_test.go b/tool/tctl/common/terraform_command_test.go index e97ef17795b52..e1db99aa73347 100644 --- a/tool/tctl/common/terraform_command_test.go +++ b/tool/tctl/common/terraform_command_test.go @@ -23,81 +23,66 @@ import ( "os" "testing" - "github.com/google/go-cmp/cmp" - "github.com/gravitational/trace" "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_createRoleIfNeeded(t *testing.T) { +func TestTerraformCommand_checkIfRoleExists(t *testing.T) { // Test setup authHelper := integration.MinimalAuthHelper{} adminClient := authHelper.StartServer(t) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - // Setting labels allows us to check whether the role - // has been updated by the helper or not. - defaultLabels := terraformRoleSpec.Allow.AppLabels - testLabels := types.Labels{"foo": []string{"bar"}} - existingRoleSpec := terraformRoleSpec - existingRoleSpec.Allow.AppLabels = testLabels - - newRoleFixture := func(t *testing.T, name string) types.Role { - role, err := types.NewRole(name, existingRoleSpec) - require.NoError(t, err) + newRoleFixture := func(name string) types.Role { + role := services.NewPresetTerraformProviderRole() + role.SetName(name) return role } tests := []struct { name string // Test setup - resourcePrefixFlag string - existingRoleFlag string - fixture types.Role + existingRoleFlag string + fixture types.Role // Test validation - wantErr require.ErrorAssertionFunc - expectedRoleName string - expectedRoleAppLabels types.Labels + expectedRoleName string + expectedErr require.ErrorAssertionFunc }{ { - name: "Create role when not exist", - wantErr: require.NoError, - expectedRoleAppLabels: defaultLabels, - expectedRoleName: terraformHelperDefaultResourcePrefix + "provider", - }, - { - name: "Update existing role", - fixture: newRoleFixture(t, terraformHelperDefaultResourcePrefix+"provider"), - wantErr: require.NoError, - expectedRoleAppLabels: defaultLabels, - expectedRoleName: terraformHelperDefaultResourcePrefix + "provider", + name: "Succeeds if preset role is found", + existingRoleFlag: "", + fixture: newRoleFixture(teleport.PresetTerraformProviderRoleName), + expectedRoleName: teleport.PresetTerraformProviderRoleName, + expectedErr: require.NoError, }, { - name: "Honour resource prefix", - resourcePrefixFlag: "test-", - wantErr: require.NoError, - expectedRoleName: "test-provider", - expectedRoleAppLabels: defaultLabels, + name: "Fails if preset role is not found", + existingRoleFlag: "", + expectedRoleName: teleport.PresetTerraformProviderRoleName, + expectedErr: require.Error, }, { - name: "Does not change existing role", - existingRoleFlag: "existing-role", - fixture: newRoleFixture(t, "existing-role"), - wantErr: require.NoError, - expectedRoleName: "existing-role", - expectedRoleAppLabels: testLabels, + 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 existing role is not found", + name: "Fails if custom existing role is specified and does not exist", existingRoleFlag: "existing-role", - wantErr: require.Error, + expectedRoleName: "existing-role", + expectedErr: require.Error, }, } for _, tt := range tests { @@ -108,33 +93,20 @@ func TestTerraformCommand_createRoleIfNeeded(t *testing.T) { _, err := adminClient.CreateRole(ctx, tt.fixture) require.NoError(t, err) } - // mimick the kingpin default behaviour - resourcePrefix := tt.resourcePrefixFlag - if resourcePrefix == "" { - resourcePrefix = terraformHelperDefaultResourcePrefix - } // Test execution c := &TerraformCommand{ - resourcePrefix: resourcePrefix, - existingRole: tt.existingRoleFlag, - userOutput: os.Stderr, + existingRole: tt.existingRoleFlag, + userOutput: os.Stderr, + log: utils.NewSlogLoggerForTests(), } - roleName, err := c.createRoleIfNeeded(ctx, adminClient) - tt.wantErr(t, err) + roleName, err := c.checkIfRoleExists(ctx, adminClient) + tt.expectedErr(t, err) require.Equal(t, tt.expectedRoleName, roleName) - if tt.expectedRoleAppLabels != nil { - gotRole, err := adminClient.GetRole(ctx, roleName) - require.NoError(t, err) - require.Empty(t, cmp.Diff(tt.expectedRoleAppLabels, gotRole.GetAppLabels(types.Allow))) - } // Test cleanup - if roleName != "" { - err = adminClient.DeleteRole(ctx, roleName) - if !trace.IsNotFound(err) { - require.NoError(t, err) - } + if tt.fixture != nil { + require.NoError(t, adminClient.DeleteRole(ctx, roleName)) } }) } From 111c11501c43de6951b0f736e12bdd5e279e32db Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Thu, 4 Jul 2024 11:11:55 -0400 Subject: [PATCH 09/12] rename use-existing-role -> role, and stop hijacking identity.SSHCACertBytes --- tool/tctl/common/terraform_command.go | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index cfcc822303974..d5e282753abca 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -97,8 +97,8 @@ func (c *TerraformCommand) Initialize(app *kingpin.Application, cfg *servicecfg. 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( - "use-existing-role", - "Existing Terraform role to use instead of creating a new one.", + "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 @@ -160,11 +160,11 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient switch { case trace.IsNotFound(err) && c.existingRole == "": return trace.Wrap(err, `The Terraform role %q does not exist in your Teleport cluster. -This role is included by default in Teleport clusters whose version is higher than v16.1 or v17. +This default role is included by default 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 --use-existing-role .`, roleName) +yourself and set the flag --role .`, roleName) case trace.IsNotFound(err) && c.existingRole != "": - return trace.Wrap(err, `The Terraform role %q specified with --use-existing-role does not exist in your Teleport cluster. + 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. @@ -188,12 +188,12 @@ If you got a role granted recently, you might have to run "tsh logout" and login // Now run tbot c.showProgress("🤖 Using the temporary bot to obtain certificates") - id, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client) + 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) + envVars, err := identityToTerraformEnvVars(addr.String(), id, sshHostCACerts) if err != nil { return trace.Wrap(err, "exporting identity into environment variables") } @@ -287,7 +287,9 @@ func (c *TerraformCommand) checkIfRoleExists(ctx context.Context, client roleCli // 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. -func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client) (*identity.Identity, error) { +// 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: "", @@ -315,49 +317,46 @@ func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr util // (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, trace.Wrap(err, "getting cluster CA certificate") + return nil, nil, trace.Wrap(err, "getting cluster CA certificate") } caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA) if err != nil { - return nil, trace.Wrap(err, "calculating CA pins") + return nil, nil, trace.Wrap(err, "calculating CA pins") } cfg.Onboarding.CAPins = caPins } err := cfg.CheckAndSetDefaults() if err != nil { - return nil, trace.Wrap(err, "checking the bot's configuration") + 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, trace.Wrap(err, "running the bot") + return nil, nil, trace.Wrap(err, "running the bot") } // Retrieve the credentials obtained by tbot. facade, err := credential.Facade() if err != nil { - return nil, trace.Wrap(err, "accessing credentials") + return nil, nil, trace.Wrap(err, "accessing credentials") } id := facade.Get() clusterName, err := clt.GetClusterName() if err != nil { - return nil, trace.Wrap(err, "retrieving cluster name") + 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, trace.Wrap(err, "retrieving SSH Host CA") + return nil, nil, trace.Wrap(err, "retrieving SSH Host CA") } - id.SSHCACertBytes = [][]byte{ - []byte(knownHosts), - } - // End of workaround + sshHostCACerts := [][]byte{[]byte(knownHosts)} - return id, nil + return id, sshHostCACerts, nil } // showProgress sends status update messages ot the user. @@ -367,7 +366,8 @@ func (c *TerraformCommand) showProgress(update string) { // identityToTerraformEnvVars takes an identity and builds environment variables // configuring the Terraform provider to use this identity. -func identityToTerraformEnvVars(addr string, id *identity.Identity) (map[string]string, error) { +// 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{ @@ -375,7 +375,7 @@ func identityToTerraformEnvVars(addr string, id *identity.Identity) (map[string] TLS: id.TLSCertBytes, }, CACerts: identityfile.CACerts{ - SSH: id.SSHCACertBytes, + SSH: sshHostCACerts, TLS: id.TLSCACertsBytes, }, } From c7f065af92400d3adae63cd8232094c968d99b5b Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Thu, 4 Jul 2024 14:09:05 -0400 Subject: [PATCH 10/12] Make the terraform provider role a real preset, rename to 'terraform-provider' --- constants.go | 7 ++++--- lib/auth/init_test.go | 1 + lib/services/presets.go | 16 ++++++++++++++-- rfd/0173-terraform-machine-id.md | 11 +++++------ tool/tctl/common/terraform_command.go | 6 +++--- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/constants.go b/constants.go index 9603b5810c553..43b2bc9e1e68e 100644 --- a/constants.go +++ b/constants.go @@ -675,6 +675,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" @@ -693,9 +697,6 @@ const ( // access to Okta resources. This will be used by the Okta requester role to // search for Okta resources. SystemOktaAccessRoleName = "okta-access" - // PresetTerraformProviderRoleName is a name of a default role that allows the Terraform provider - // to configure all its supported Teleport resources. - PresetTerraformProviderRoleName = "terraform-provider-all-resources" ) var PresetRoles = []string{PresetEditorRoleName, PresetAccessRoleName, PresetAuditorRoleName} diff --git a/lib/auth/init_test.go b/lib/auth/init_test.go index 10e7338d01629..225da8574d8de 100644 --- a/lib/auth/init_test.go +++ b/lib/auth/init_test.go @@ -493,6 +493,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 46b1b52d6f921..92acf97e41444 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -565,7 +565,7 @@ func NewPresetTerraformProviderRole() types.Role { Namespace: apidefaults.Namespace, Description: "Default Terraform provider role", Labels: map[string]string{ - types.TeleportInternalResourceType: types.SystemResource, + types.TeleportInternalResourceType: types.PresetResource, }, }, Spec: types.RoleSpecV6{ @@ -653,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 { @@ -785,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/rfd/0173-terraform-machine-id.md b/rfd/0173-terraform-machine-id.md index dc8830ac18ac6..6d7b106346fbc 100644 --- a/rfd/0173-terraform-machine-id.md +++ b/rfd/0173-terraform-machine-id.md @@ -115,16 +115,15 @@ we used for the Teleport operator. #### Resource bootstrapping -Teleport will now ship with a default Terraform provider role: `terraform-provider-all-resources` automatically updated using +Teleport will now ship with a preset Terraform provider role: `terraform-provider` automatically updated using our existing preset logic. This will allow users to benefit from the new resources supported by the Terraform provider -without having to update their preset role. The suffix `-all-resources` is here to minimize the risk of conflict with existing -terraform provider roles. +without having to update their preset role. If a role with this name already exists, Teleport will not modify it. We will automatically create the resources required by the Terraform provider before executing Terraform: -- the `terraform-provider-` bot allowed to issue certificates for the `terraform-provider` role. +- the `tctl-terraform-env-` bot allowed to issue certificates for the `terraform-provider` role. This resource expires by default after 1h. -- the `terraform-provider-` secret random provision token allowing to join as - the `terraform-provider-` bot. This resource expires by default after 1h but will be consumed +- the random secret token allowing to join as + the `tctl-terraform-env-` bot. This resource expires by default after 1h but will be consumed automatically on join. Every bootstrapped resource will be annotated with `teleport.dev/created-by: tctl-terraform-env` diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index d5e282753abca..27da7fe5edce2 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -160,7 +160,7 @@ func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient 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 by default in Teleport clusters whose version is higher than v16.1 or v17. +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 != "": @@ -178,8 +178,8 @@ If you got a role granted recently, you might have to run "tsh logout" and login // 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. -To use the "tctl terraform env" command you must have rights to create Teleport bot resources. + 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 { From f4dd7aa5dc14c0942fcaefb913726986d573f02f Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Thu, 4 Jul 2024 14:41:08 -0400 Subject: [PATCH 11/12] lint --- tool/tctl/common/terraform_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index 27da7fe5edce2..274b468afd749 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -254,7 +254,7 @@ func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, clien }, } - bot, err = client.BotServiceClient().CreateBot(ctx, &machineidv1pb.CreateBotRequest{ + _, err = client.BotServiceClient().CreateBot(ctx, &machineidv1pb.CreateBotRequest{ Bot: bot, }) if err != nil { From 05e6380befba4a70437904678f0a7e32576a2121 Mon Sep 17 00:00:00 2001 From: hugoShaka Date: Wed, 10 Jul 2024 17:35:23 -0400 Subject: [PATCH 12/12] Fix tbot's invocation after rebase --- tool/tctl/common/terraform_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go index 274b468afd749..798c4de0b9997 100644 --- a/tool/tctl/common/terraform_command.go +++ b/tool/tctl/common/terraform_command.go @@ -298,7 +298,7 @@ func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr util JoinMethod: types.JoinMethodToken, }, Storage: &config.StorageConfig{Destination: &config.DestinationMemory{}}, - Outputs: []config.Output{credential}, + Services: config.ServiceConfigs{credential}, CertificateTTL: c.botTTL, Oneshot: true, // If --insecure is passed, the bot will trust the certificate on first use.