diff --git a/cloudsmith/resource_service.go b/cloudsmith/resource_service.go index 8aefee6..1a8be96 100644 --- a/cloudsmith/resource_service.go +++ b/cloudsmith/resource_service.go @@ -221,6 +221,31 @@ func resourceServiceUpdate(ctx context.Context, d *schema.ResourceData, m interf if err := waiter(checkerFunc, defaultUpdateTimeout, defaultUpdateInterval); err != nil { return diag.Errorf("error waiting for service (%s) to be updated: %s", d.Id(), err) } + + // If the rotate_api_key field has changed to a non-empty value, trigger an + // API key refresh for this service account. The value of rotate_api_key + // itself is not sent to the API; it is only used to force a Terraform diff + // and therefore an update. Changing it to an empty value (or removing it) + // does not trigger a rotation. + if d.HasChange("rotate_api_key") { + _, newRaw := d.GetChange("rotate_api_key") + newVal, _ := newRaw.(string) + + if newVal != "" { + refreshReq := pc.APIClient.OrgsApi.OrgsServicesRefresh(pc.Auth, org, d.Id()) + refreshedService, _, err := pc.APIClient.OrgsApi.OrgsServicesRefreshExecute(refreshReq) + if err != nil { + return diag.Errorf("error rotating service (%s.%s) API key: %s", org, d.Id(), err) + } + + // Always set the refreshed key first; redaction is handled separately + // below based on the current value of store_api_key. + d.Set("key", refreshedService.GetKey()) + } + } + + // Ensure we never persist the API key in state when store_api_key is false, + // regardless of whether a rotation took place in this update. if !requiredBool(d, "store_api_key") { d.Set("key", "**redacted**") } @@ -338,6 +363,11 @@ func resourceService() *schema.Resource { Optional: true, Default: true, }, + "rotate_api_key": { + Type: schema.TypeString, + Description: "Arbitrary value used to trigger rotation of the service's API key. Change this value to rotate the key for a service account.", + Optional: true, + }, }, } } diff --git a/cloudsmith/resource_service_test.go b/cloudsmith/resource_service_test.go index b263466..7255ccf 100644 --- a/cloudsmith/resource_service_test.go +++ b/cloudsmith/resource_service_test.go @@ -50,7 +50,7 @@ func TestAccService_basic(t *testing.T) { Check: resource.ComposeTestCheckFunc( testAccServiceCheckExists("cloudsmith_service.test"), resource.TestCheckResourceAttrSet("cloudsmith_service.test", "team.#"), - resource.TestMatchTypeSetElemNestedAttrs("cloudsmith_service.test", "team.*", map[string]*regexp.Regexp{ + resource.TestMatchTypeSetElemNestedAttrs("cloudsmith_service.test", "team.*", map[string]*regexp.Regexp{ "slug": regexp.MustCompile("^tf-test-team-svc(-[^2].*)?$"), "role": regexp.MustCompile("^Member$"), }), @@ -81,6 +81,32 @@ func TestAccService_basic(t *testing.T) { resource.TestCheckResourceAttr("cloudsmith_service.test", "key", "**redacted**"), ), }, + { + Config: testAccServiceConfigRotateAPIKeyFirst, + Check: resource.ComposeTestCheckFunc( + testAccServiceCheckExists("cloudsmith_service.test"), + // key should be present in state when store_api_key is true + resource.TestCheckResourceAttrSet("cloudsmith_service.test", "key"), + ), + }, + { + Config: testAccServiceConfigRotateAPIKeySecond, + Check: resource.ComposeTestCheckFunc( + // ensure the resource still exists after rotation + testAccServiceCheckExists("cloudsmith_service.test"), + // key should still be set after rotation; we don't assert the exact value + resource.TestCheckResourceAttrSet("cloudsmith_service.test", "key"), + ), + }, + { + Config: testAccServiceConfigRotateAPIKeyStoreFalse, + Check: resource.ComposeTestCheckFunc( + testAccServiceCheckExists("cloudsmith_service.test"), + // when rotating with store_api_key = false, the key must be redacted in state + resource.TestCheckResourceAttr("cloudsmith_service.test", "store_api_key", "false"), + resource.TestCheckResourceAttr("cloudsmith_service.test", "key", "**redacted**"), + ), + }, { ResourceName: "cloudsmith_service.test", ImportState: true, @@ -93,7 +119,7 @@ func TestAccService_basic(t *testing.T) { ), nil }, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"key", "store_api_key"}, + ImportStateVerifyIgnore: []string{"key", "store_api_key", "rotate_api_key"}, }, }, }) @@ -173,6 +199,31 @@ resource "cloudsmith_service" "test" { } `, os.Getenv("CLOUDSMITH_NAMESPACE")) +var testAccServiceConfigRotateAPIKeyFirst = fmt.Sprintf(` +resource "cloudsmith_service" "test" { + name = "TF Test Service cs" + organization = "%s" + rotate_api_key = "first-rotation" +} +`, os.Getenv("CLOUDSMITH_NAMESPACE")) + +var testAccServiceConfigRotateAPIKeySecond = fmt.Sprintf(` +resource "cloudsmith_service" "test" { + name = "TF Test Service cs" + organization = "%s" + rotate_api_key = "second-rotation" +} +`, os.Getenv("CLOUDSMITH_NAMESPACE")) + +var testAccServiceConfigRotateAPIKeyStoreFalse = fmt.Sprintf(` +resource "cloudsmith_service" "test" { + name = "TF Test Service cs" + organization = "%s" + store_api_key = false + rotate_api_key = "third-rotation" +} +`, os.Getenv("CLOUDSMITH_NAMESPACE")) + var testAccServiceConfigBasicAddToTeam = fmt.Sprintf(` resource "cloudsmith_team" "test" { name = "TF Test Team Svc" diff --git a/docs/resources/service.md b/docs/resources/service.md index 71e63ab..2f610e7 100644 --- a/docs/resources/service.md +++ b/docs/resources/service.md @@ -42,6 +42,7 @@ The following arguments are supported: * `role` - (Optional) The service's role in the team. If defined, must be one of `Member` or `Manager`. * `slug` - (Required) The team the service should be added to. * `store_api_key` - (Optional) The service's API key to be returned in state. Defaults to `true`. If set to `false`, the "key" value is replaced with `**redacted**`. **NOTE:** This will only be applied to newly created service accounts, **this won't take effect for existing service accounts**. +* `rotate_api_key` - (Optional) Arbitrary string used to trigger rotation of the service's API key. Setting this to a non-empty value or changing it between non-empty values (for example from `first-rotation` to `second-rotation`) will rotate the API key for the service account. Removing this field or setting it back to an empty value will not trigger a rotation. ## Attribute Reference