diff --git a/CHANGELOG.md b/CHANGELOG.md index 5052dc1..456fd53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ FEATURES: * Added optional description for user token roles to provide a description in HCPTF UI: https://github.com/hashicorp/vault-plugin-secrets-terraform/pull/84 +* Added support for HCP TF multiple team tokens. Introduced new optional parameter `credential_type` that can be used with value `team` to issue multiple team tokens. Tokens can optionally have a ttl and max_ttl that is respected via normal lease operations as well as Expired by HCP TF API at their expected time: https://github.com/hashicorp/vault-plugin-secrets-terraform/pull/89 + ## 0.11.0 ### FEb 7, 2025 IMPROVEMENTS: diff --git a/backend_test.go b/backend_test.go index 62b877b..1312866 100644 --- a/backend_test.go +++ b/backend_test.go @@ -113,7 +113,7 @@ func (e *testEnv) ReadOrgToken(t *testing.T) { require.NotNil(t, ot) } -func (e *testEnv) AddTeamTokenRole(t *testing.T) { +func (e *testEnv) AddTeamLegacyTokenRole(t *testing.T) { req := &logical.Request{ Operation: logical.UpdateOperation, Path: "role/test-team-token", @@ -128,7 +128,7 @@ func (e *testEnv) AddTeamTokenRole(t *testing.T) { require.Nil(t, resp) } -func (e *testEnv) ReadTeamToken(t *testing.T) { +func (e *testEnv) ReadTeamLegacyToken(t *testing.T) { req := &logical.Request{ Operation: logical.ReadOperation, Path: "creds/test-team-token", @@ -155,6 +155,65 @@ func (e *testEnv) ReadTeamToken(t *testing.T) { } } +func (e *testEnv) AddMultiTeamTokenRole(t *testing.T) { + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "role/test-multiteam-token", + Storage: e.Storage, + Data: map[string]interface{}{ + "team_id": e.TeamID, + "description": e.Description, + "credential_type": "team", + "ttl": "1m", + }, + } + resp, err := e.Backend.HandleRequest(e.Context, req) + require.Nil(t, resp) + require.Nil(t, err) +} + +func (e *testEnv) ReadMultiTeamToken(t *testing.T) { + req := &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/test-multiteam-token", + Storage: e.Storage, + } + resp, err := e.Backend.HandleRequest(e.Context, req) + require.Nil(t, err) + require.NotNil(t, resp) + if t, ok := resp.Data["token_id"]; ok { + e.TokenIDs = append(e.TokenIDs, t.(string)) + } + require.NotEmpty(t, resp.Data["token"]) + + if e.SecretToken != "" { + require.NotEqual(t, e.SecretToken, resp.Data["token"]) + } + + // collect secret IDs to revoke at end of test + require.NotNil(t, resp.Secret) + if t, ok := resp.Secret.InternalData["token_id"]; ok { + e.SecretToken = t.(string) + } +} + +func (e *testEnv) CleanupMultiTeamTokens(t *testing.T) { + if len(e.TokenIDs) == 0 { + t.Fatalf("expected 2 tokens, got: %d", len(e.TokenIDs)) + } + + for _, id := range e.TokenIDs { + b := e.Backend.(*tfBackend) + client, err := b.getClient(e.Context, e.Storage) + if err != nil { + t.Fatal("fatal getting client") + } + if err := client.TeamTokens.DeleteByID(e.Context, id); err != nil { + t.Fatalf("unexpected error deleting multiteam token: %s", err) + } + } +} + func (e *testEnv) AddUserTokenRole(t *testing.T) { req := &logical.Request{ Operation: logical.UpdateOperation, diff --git a/client.go b/client.go index cb3808a..ee344d2 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ package tfc import ( "errors" + "time" "github.com/hashicorp/go-tfe" ) @@ -14,9 +15,10 @@ type client struct { } type terraformToken struct { - ID string `json:"id"` - Description string `json:"description"` - Token string `json:"token"` + ID string `json:"id"` + Description string `json:"description"` + Token string `json:"token"` + ExpiredAt time.Time `json:"expired_at,omitempty"` } func newClient(config *tfConfig) (*client, error) { diff --git a/go.mod b/go.mod index 73c78e9..6408489 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,8 @@ toolchain go1.22.0 require ( github.com/hashicorp/go-hclog v1.6.3 - github.com/hashicorp/go-tfe v1.74.1 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 + github.com/hashicorp/go-tfe v1.78.0 github.com/hashicorp/vault/api v1.14.0 github.com/hashicorp/vault/sdk v0.13.0 github.com/stretchr/testify v1.10.0 @@ -45,14 +46,13 @@ require ( github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect github.com/hashicorp/go-secure-stdlib/plugincontainer v0.3.0 // indirect - github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect - github.com/hashicorp/go-slug v0.16.3 // indirect + github.com/hashicorp/go-slug v0.16.4 // indirect github.com/hashicorp/go-sockaddr v1.0.6 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.1-vault-5 // indirect - github.com/hashicorp/jsonapi v1.3.2 // indirect + github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -86,7 +86,7 @@ require ( golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.9.0 // indirect + golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect diff --git a/go.sum b/go.sum index 9fbe001..7a86db4 100644 --- a/go.sum +++ b/go.sum @@ -117,12 +117,12 @@ github.com/hashicorp/go-secure-stdlib/plugincontainer v0.3.0 h1:KMWpBsC65ZBXDpox github.com/hashicorp/go-secure-stdlib/plugincontainer v0.3.0/go.mod h1:qKYwSZ2EOpppko5ud+Sh9TrUgiTAZSaQCr8XWIYXsbM= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= -github.com/hashicorp/go-slug v0.16.3 h1:pe0PMwz2UWN1168QksdW/d7u057itB2gY568iF0E2Ns= -github.com/hashicorp/go-slug v0.16.3/go.mod h1:THWVTAXwJEinbsp4/bBRcmbaO5EYNLTqxbG4tZ3gCYQ= +github.com/hashicorp/go-slug v0.16.4 h1:kI0mOUVjbBsyocwO29pZIQzzkBnfQNdU4eqlUpNdNVA= +github.com/hashicorp/go-slug v0.16.4/go.mod h1:THWVTAXwJEinbsp4/bBRcmbaO5EYNLTqxbG4tZ3gCYQ= github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I= github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= -github.com/hashicorp/go-tfe v1.74.1 h1:I/8fOwSYox17IZV7SULIQH0ZRPNL2g/biW6hHWnOTVY= -github.com/hashicorp/go-tfe v1.74.1/go.mod h1:kGHWMZ3HHjitgqON8nBZ4kPVJ3cLbzM4JMgmNVMs9aQ= +github.com/hashicorp/go-tfe v1.78.0 h1:RMkrEO3N4hbnXqoMWl44TnSCkMXpON5iEOOJf+UxWAo= +github.com/hashicorp/go-tfe v1.78.0/go.mod h1:6dUFMBKh0jkxlRsrw7bYD2mby0efdwE4dtlAuTogIzA= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -133,8 +133,8 @@ github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+l github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/jsonapi v1.3.2 h1:gP3fX2ZT7qXi+PbwieptzkspIohO2kCSiBUvUTBAbMs= -github.com/hashicorp/jsonapi v1.3.2/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= +github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e h1:xwy/1T0cxHWaLx2MM0g4BlaQc1BXn/9835mPrBqwSPU= +github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= github.com/hashicorp/vault/api v1.14.0 h1:Ah3CFLixD5jmjusOgm8grfN9M0d+Y8fVR2SW0K6pJLU= github.com/hashicorp/vault/api v1.14.0/go.mod h1:pV9YLxBGSz+cItFDd8Ii4G17waWOQ32zVjMWHe/cOqk= github.com/hashicorp/vault/sdk v0.13.0 h1:UmcLF+7r70gy1igU44Suflgio30P2GOL4MkHPhJuiP8= @@ -311,8 +311,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/path_credentials.go b/path_credentials.go index 2c6bce7..77a1e68 100644 --- a/path_credentials.go +++ b/path_credentials.go @@ -72,8 +72,8 @@ func (b *tfBackend) pathCredentialsRead(ctx context.Context, req *logical.Reques return nil, errors.New("error retrieving role: role is nil") } - if roleEntry.UserID != "" { - return b.createUserCreds(ctx, req, roleEntry) + if roleEntry.CredentialType == userCredentialType || roleEntry.CredentialType == teamCredentialType { + return b.createUserOrMultiTeamCreds(ctx, req, roleEntry) } resp := &logical.Response{ @@ -92,7 +92,7 @@ func (b *tfBackend) pathCredentialsRead(ctx context.Context, req *logical.Reques return resp, nil } -func (b *tfBackend) createUserCreds(ctx context.Context, req *logical.Request, role *terraformRoleEntry) (*logical.Response, error) { +func (b *tfBackend) createUserOrMultiTeamCreds(ctx context.Context, req *logical.Request, role *terraformRoleEntry) (*logical.Response, error) { token, err := b.createToken(ctx, req.Storage, role) if err != nil { return nil, err @@ -103,8 +103,12 @@ func (b *tfBackend) createUserCreds(ctx context.Context, req *logical.Request, r "token_id": token.ID, } - if role.Description != "" { - data["description"] = role.Description + if token.Description != "" { + data["description"] = token.Description + } + + if !token.ExpiredAt.IsZero() { + data["expired_at"] = token.ExpiredAt } resp := b.Secret(terraformTokenType).Response(data, map[string]interface{}{ @@ -135,7 +139,12 @@ func (b *tfBackend) createToken(ctx context.Context, s logical.Storage, roleEntr case isOrgToken(roleEntry.Organization, roleEntry.TeamID): token, err = createOrgToken(ctx, client, roleEntry.Organization) case isTeamToken(roleEntry.TeamID): - token, err = createTeamToken(ctx, client, roleEntry.TeamID) + if roleEntry.CredentialType == teamCredentialType { + token, err = createTeamTokenWithOptions(ctx, client, *roleEntry, b.System().MaxLeaseTTL()) + } else { + // team_legacy tokens + token, err = createTeamLegacyToken(ctx, client, roleEntry.TeamID) + } default: token, err = createUserToken(ctx, client, roleEntry.UserID, roleEntry.Description) } diff --git a/path_credentials_test.go b/path_credentials_test.go index 92f9975..0d619d4 100644 --- a/path_credentials_test.go +++ b/path_credentials_test.go @@ -35,6 +35,7 @@ func newAcceptanceTestEnv() (*testEnv, error) { Organization: os.Getenv(envVarTerraformOrganization), TeamID: os.Getenv(envVarTerraformTeamID), UserID: os.Getenv(envVarTerraformUserID), + Description: "acc-test", Backend: b, Context: ctx, Storage: &logical.InmemStorage{}, @@ -56,7 +57,7 @@ func TestAcceptanceOrganizationToken(t *testing.T) { t.Run("read organization token cred", acceptanceTestEnv.ReadOrgToken) } -func TestAcceptanceTeamToken(t *testing.T) { +func TestAcceptanceTeamLegacyToken(t *testing.T) { if !runAcceptanceTests { t.SkipNow() } @@ -67,8 +68,25 @@ func TestAcceptanceTeamToken(t *testing.T) { } t.Run("add config", acceptanceTestEnv.AddConfig) - t.Run("add team token role", acceptanceTestEnv.AddTeamTokenRole) - t.Run("read team token cred", acceptanceTestEnv.ReadTeamToken) + t.Run("add team token role", acceptanceTestEnv.AddTeamLegacyTokenRole) + t.Run("read team token cred", acceptanceTestEnv.ReadTeamLegacyToken) +} + +func TestAcceptanceMultiTeamToken(t *testing.T) { + if !runAcceptanceTests { + t.SkipNow() + } + + acceptanceTestEnv, err := newAcceptanceTestEnv() + if err != nil { + t.Fatal(err) + } + + t.Run("add config", acceptanceTestEnv.AddConfig) + t.Run("add multiteam token role", acceptanceTestEnv.AddMultiTeamTokenRole) + t.Run("read multiteam token cred", acceptanceTestEnv.ReadMultiTeamToken) + t.Run("read multiteam token cred", acceptanceTestEnv.ReadMultiTeamToken) + t.Run("cleanup multiteam tokens", acceptanceTestEnv.CleanupMultiTeamTokens) } func TestAcceptanceUserToken(t *testing.T) { diff --git a/path_roles.go b/path_roles.go index 73025e1..585c0c7 100644 --- a/path_roles.go +++ b/path_roles.go @@ -8,21 +8,39 @@ import ( "fmt" "time" + "github.com/hashicorp/go-secure-stdlib/strutil" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" ) +const ( + userCredentialType = "user" + organizationCredentialType = "organization" + teamLegacyCredentialType = "team_legacy" + teamCredentialType = "team" +) + +func credentialType_Values() []string { + return []string{ + userCredentialType, + organizationCredentialType, + teamLegacyCredentialType, + teamCredentialType, + } +} + // terraformRoleEntry is a Vault role construct that maps to TFC/TFE type terraformRoleEntry struct { - Name string `json:"name"` - Organization string `json:"organization,omitempty"` - TeamID string `json:"team_id,omitempty"` - UserID string `json:"user_id,omitempty"` - Description string `json:"description,omitempty"` - TTL time.Duration `json:"ttl"` - MaxTTL time.Duration `json:"max_ttl"` - Token string `json:"token,omitempty"` - TokenID string `json:"token_id,omitempty"` + Name string `json:"name"` + Organization string `json:"organization,omitempty"` + TeamID string `json:"team_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Description string `json:"description,omitempty"` + TTL time.Duration `json:"ttl"` + MaxTTL time.Duration `json:"max_ttl"` + CredentialType string `json:"credential_type,omitempty"` + Token string `json:"token,omitempty"` + TokenID string `json:"token_id,omitempty"` } func (r *terraformRoleEntry) toResponseData() map[string]interface{} { @@ -36,13 +54,24 @@ func (r *terraformRoleEntry) toResponseData() map[string]interface{} { } if r.Organization != "" { respData["organization"] = r.Organization + r.CredentialType = organizationCredentialType } if r.TeamID != "" { respData["team_id"] = r.TeamID + // Default to legacy team credential type + if r.CredentialType == "" || r.CredentialType == teamLegacyCredentialType { + r.CredentialType = teamLegacyCredentialType + respData["credential_type"] = teamLegacyCredentialType + } else { + respData["credential_type"] = teamCredentialType + } } if r.UserID != "" { respData["user_id"] = r.UserID + r.CredentialType = userCredentialType + respData["credential_type"] = userCredentialType } + return respData } @@ -86,6 +115,10 @@ func pathRole(b *tfBackend) []*framework.Path { Type: framework.TypeDurationSecond, Description: "Maximum time for role. If not set or set to 0, will use system default.", }, + "credential_type": { + Type: framework.TypeString, + Description: "Credential type to be used for the token. Can be either 'user', 'org', 'team', or 'team_legacy'(deprecated).", + }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ @@ -162,16 +195,34 @@ func (b *tfBackend) pathRolesWrite(ctx context.Context, req *logical.Request, d } roleEntry.Name = name - if organization, ok := d.GetOk("organization"); ok { - roleEntry.Organization = organization.(string) + + // Will set if users dont. Must be set for multi-team tokens + if credentialTypeRaw, ok := d.GetOk("credential_type"); ok { + roleEntry.CredentialType = credentialTypeRaw.(string) + if !strutil.StrListContains(credentialType_Values(), roleEntry.CredentialType) { + return logical.ErrorResponse("unrecognized credential type: %s", roleEntry.CredentialType), nil + } } - if teamID, ok := d.GetOk("team_id"); ok { - roleEntry.TeamID = teamID.(string) + if organization, ok := d.GetOk("organization"); ok { + roleEntry.Organization = organization.(string) + if roleEntry.CredentialType == "" { + roleEntry.CredentialType = organizationCredentialType + } } if userID, ok := d.GetOk("user_id"); ok { roleEntry.UserID = userID.(string) + if roleEntry.CredentialType == "" { + roleEntry.CredentialType = userCredentialType + } + } + + if teamID, ok := d.GetOk("team_id"); ok { + roleEntry.TeamID = teamID.(string) + if roleEntry.CredentialType == "" || roleEntry.CredentialType == teamLegacyCredentialType { + roleEntry.CredentialType = teamLegacyCredentialType + } } if description, ok := d.GetOk("description"); ok { @@ -198,10 +249,16 @@ func (b *tfBackend) pathRolesWrite(ctx context.Context, req *logical.Request, d return logical.ErrorResponse("ttl cannot be greater than max_ttl"), nil } + if roleEntry.CredentialType == teamLegacyCredentialType { + if roleEntry.Description != "" || roleEntry.TTL != 0 || roleEntry.MaxTTL != 0 { + return logical.ErrorResponse("cannot provide description, ttl, or max_ttl with credential_type = team_legacy, try credential_type = team."), fmt.Errorf("test error") + } + } + // if we're creating a role to manage a Team or Organization, we need to // create the token now. User tokens will be created when credentials are // read. - if roleEntry.Organization != "" || roleEntry.TeamID != "" { + if roleEntry.CredentialType == organizationCredentialType || roleEntry.CredentialType == teamLegacyCredentialType { token, err := b.createToken(ctx, req.Storage, roleEntry) if err != nil { return nil, err @@ -271,16 +328,30 @@ const ( pathRoleHelpDescription = ` This path allows you to read and write roles used to generate Terraform Cloud / Enterprise tokens. You can configure a role to manage an organization's token, a -team's token, or a user's dynamic tokens. - -A Terraform Cloud/Enterprise Organization can only have one active token at a -time. To manage an Organization's token, set the organization field. - -A Terraform Cloud/Enterprise Team can only have one active token at a time. To -manage a Teams's token, set the team_id field. +team's token, legacy team's token, or a user's dynamic tokens, based on the +credential_type. The credential_type is used to determine the type of token +to be generated. The credential_type can be one of the following: +- user: A user token. +- organization: An organization token. +- team: A team token. This is the recommend team token credential type. +- team_legacy: A legacy team token. This is the default credential type if + team_id is set but credential_type is left empty. + +credential_type "user" can have multiple API tokens. To manage a user token, you +can user_id and credential_type "user". When issuing a call to create creds, this role +will be used to generate the token. + +credential_type "team" can have multiple API tokens. This is the recommended +team token credential type. To manage a team token, you can set a team_id +and set credential_type to "team". When issuing a call to create creds, this role +will be used to generate the token. You can set a ttl and max_ttl. Max_ttl will +also set an expiration timer on the terraform token (including the system max ttl). + +credential_type "organization" or "team_legacy" can only have one active token at a +time. When a new token is created, the old token will be revoked. This is +because Terraform Cloud/Enterprise does not support multiple active tokens for these +types. -A Terraform Cloud/Enterprise User can have multiple API tokens. To manage a -User's token, set the user_id field. ` pathRoleListHelpSynopsis = `List the existing roles in Terraform Cloud / Enterprise backend` diff --git a/path_roles_test.go b/path_roles_test.go index 95c97c8..d97397e 100644 --- a/path_roles_test.go +++ b/path_roles_test.go @@ -67,9 +67,20 @@ func TestTokenRole(t *testing.T) { require.Len(t, resp.Data["keys"].([]string), 10) }) - t.Run("Test Token Roles", func(t *testing.T) { + t.Run("Test Legacy Team Token Role - Fail", func(t *testing.T) { + resp, _ := testTokenRoleCreate(t, b, s, roleName, map[string]interface{}{ + "team_id": "abcd", + "ttl": testMaxTTL, + "max_ttl": testTTL, + "credential_type": "team_legacy", + }) + + require.Contains(t, resp.Data["error"], "ttl cannot be greater than max_ttl") + }) + + t.Run("Test Legacy Team Token Role", func(t *testing.T) { resp, err := testTokenRoleCreate(t, b, s, roleName, map[string]interface{}{ - "organization": organization, + "team_id": teamID, }) require.NoError(t, err) @@ -80,13 +91,10 @@ func TestTokenRole(t *testing.T) { require.NoError(t, err) require.Equal(t, roleName, resp.Data["name"]) - require.Equal(t, organization, resp.Data["organization"]) - require.Empty(t, resp.Data["team_id"]) + require.Equal(t, teamID, resp.Data["team_id"]) resp, err = testTokenRoleUpdate(t, b, s, map[string]interface{}{ "team_id": teamID, - "ttl": testTTL, - "max_ttl": testMaxTTL, }) require.NoError(t, err) @@ -94,8 +102,6 @@ func TestTokenRole(t *testing.T) { require.NoError(t, err) require.Equal(t, roleName, resp.Data["name"]) require.Equal(t, teamID, resp.Data["team_id"]) - require.Equal(t, float64(testTTL), resp.Data["ttl"]) - require.Equal(t, float64(testMaxTTL), resp.Data["max_ttl"]) _, err = testTokenRoleDelete(t, b, s) require.NoError(t, err) @@ -107,8 +113,7 @@ func TestTokenRole(t *testing.T) { t.Run("Create Team Token Role", func(t *testing.T) { resp, err := testTokenRoleCreate(t, b, s, roleName, map[string]interface{}{ - "organization": organization, - "team_id": teamID, + "team_id": teamID, }) require.NoError(t, err) @@ -119,11 +124,66 @@ func TestTokenRole(t *testing.T) { require.NoError(t, err) require.Equal(t, roleName, resp.Data["name"]) - require.Equal(t, organization, resp.Data["organization"]) require.Equal(t, teamID, resp.Data["team_id"]) }) } +func TestMultiTeamRole(t *testing.T) { + if !runAcceptanceTests { + t.SkipNow() + } + + b, s := getTestBackend(t) + + teamID := checkEnvVars(t, envVarTerraformTeamID) + + descriptionOriginal := "description1" + descriptionUpdated := "description2" + + t.Run("Create MultiTeam Role - pass", func(t *testing.T) { + resp, err := testTokenRoleCreate(t, b, s, roleName, map[string]interface{}{ + "team_id": teamID, + "credential_type": "team", + "max_ttl": "3600", + "description": descriptionOriginal, + }) + + require.Nil(t, err) + require.Nil(t, resp.Error()) + require.Nil(t, resp) + }) + t.Run("Read MultiTeam Role", func(t *testing.T) { + resp, err := testTokenRoleRead(t, b, s) + + require.Nil(t, err) + require.Nil(t, resp.Error()) + require.NotNil(t, resp) + require.Equal(t, resp.Data["team_id"], teamID) + require.Equal(t, resp.Data["description"], descriptionOriginal) // cred description includes random int + }) + t.Run("Update MultiTeam Role", func(t *testing.T) { + resp, err := testTokenRoleUpdate(t, b, s, map[string]interface{}{ + "credential_type": "team", + "ttl": "1m", + "max_ttl": "5h", + "description": descriptionUpdated, + }) + + require.Nil(t, err) + require.Nil(t, resp.Error()) + require.Nil(t, resp) + }) + t.Run("Re-read MultiTeam Role", func(t *testing.T) { + resp, err := testTokenRoleRead(t, b, s) + + require.Nil(t, err) + require.Nil(t, resp.Error()) + require.NotNil(t, resp) + require.Equal(t, resp.Data["team_id"], teamID) + require.Equal(t, resp.Data["description"], descriptionUpdated) + }) +} + func TestUserRole(t *testing.T) { if !runAcceptanceTests { t.SkipNow() diff --git a/path_rotate_role.go b/path_rotate_role.go index 9926787..71c5bb4 100644 --- a/path_rotate_role.go +++ b/path_rotate_role.go @@ -61,6 +61,10 @@ func (b *tfBackend) pathRotateRole(ctx context.Context, req *logical.Request, d return logical.ErrorResponse("cannot rotate credentials for user roles"), nil } + if roleEntry.TeamID != "" && roleEntry.CredentialType == teamCredentialType { + return logical.ErrorResponse("cannot rotate credentials for credential_type = team token roles. Only works for credential_type = team_legacy."), nil + } + token, err := b.createToken(ctx, req.Storage, roleEntry) if err != nil { return nil, err diff --git a/terraform_token.go b/terraform_token.go index 27e8d95..a5fbd83 100644 --- a/terraform_token.go +++ b/terraform_token.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "math/rand" + "time" "github.com/hashicorp/go-tfe" "github.com/hashicorp/vault/sdk/framework" @@ -42,7 +44,7 @@ func createOrgToken(ctx context.Context, c *client, organization string) (*terra }, nil } -func createTeamToken(ctx context.Context, c *client, teamID string) (*terraformToken, error) { +func createTeamLegacyToken(ctx context.Context, c *client, teamID string) (*terraformToken, error) { if _, err := c.Teams.Read(ctx, teamID); err != nil { return nil, err } @@ -59,6 +61,33 @@ func createTeamToken(ctx context.Context, c *client, teamID string) (*terraformT }, nil } +func createTeamTokenWithOptions(ctx context.Context, c *client, roleEntry terraformRoleEntry, systemMaxTTL time.Duration) (*terraformToken, error) { + teamID := roleEntry.TeamID + + uniqueDescription := fmt.Sprintf("%s(%d)", roleEntry.Description, rand.Intn(10000)) + createOpts := tfe.TeamTokenCreateOptions{ + Description: uniqueDescription, + } + + maxTTL := max(roleEntry.MaxTTL, systemMaxTTL) + if maxTTL > 0 { + expiredAt := time.Now().Add(maxTTL) + createOpts.ExpiredAt = &expiredAt + } + + token, err := c.TeamTokens.CreateWithOptions(ctx, teamID, createOpts) + if err != nil { + return nil, err + } + + return &terraformToken{ + ID: token.ID, + Description: uniqueDescription, + Token: token.Token, + ExpiredAt: token.ExpiredAt, + }, nil +} + func createUserToken(ctx context.Context, c *client, userID string, description string) (*terraformToken, error) { token, err := c.UserTokens.Create(ctx, userID, tfe.UserTokenCreateOptions{ Description: description,