diff --git a/docs/resources/service_account_rotating_token.md b/docs/resources/service_account_rotating_token.md new file mode 100644 index 000000000..9d4b7ff60 --- /dev/null +++ b/docs/resources/service_account_rotating_token.md @@ -0,0 +1,59 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "grafana_service_account_rotating_token Resource - terraform-provider-grafana" +subcategory: "Grafana OSS" +description: |- + Note: This resource is available only with Grafana 9.1+. + Official documentation https://grafana.com/docs/grafana/latest/administration/service-accounts/HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api +--- + +# grafana_service_account_rotating_token (Resource) + +**Note:** This resource is available only with Grafana 9.1+. + +* [Official documentation](https://grafana.com/docs/grafana/latest/administration/service-accounts/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) + +## Example Usage + +```terraform +resource "grafana_service_account" "test" { + name = "test-service-account" + role = "Viewer" +} + +resource "grafana_service_account_rotating_token" "foo" { + name_prefix = "key_foo" + service_account_id = grafana_service_account.test.id + seconds_to_live = 7776000 # 3 months + early_rotation_window_seconds = 604800 # 1 week +} + +output "service_account_token_foo_key" { + value = grafana_service_account_rotating_token.foo.key + sensitive = true +} +``` + + +## Schema + +### Required + +- `early_rotation_window_seconds` (Number) Duration of the time window before expiring where the token can be rotated, in seconds. +- `name_prefix` (String) Prefix for the name of the service account tokens created by this resource. The actual name will be stored in the computed field `name`, which will be in the format `-`. +- `seconds_to_live` (Number) The token expiration in seconds. +- `service_account_id` (String) The ID of the service account to which the token belongs. + +### Optional + +- `delete_on_destroy` (Boolean) Deletes the service account token in Grafana when the resource is destroyed in Terraform, instead of leaving it to expire at its `expiration` time. Use it with `lifecycle { create_before_destroy = true }` to make sure that the new token is created before the old one is deleted. Defaults to `false`. + +### Read-Only + +- `expiration` (String) The expiration date of the service account token. +- `has_expired` (Boolean) The status of the service account token. +- `id` (String) The ID of this resource. +- `key` (String, Sensitive) The key of the service account token. +- `name` (String) The name of the service account token. It will start with `-` and will have characters appended to it to make the name unique. +- `ready_for_rotation` (Boolean) Signals that the service account token is expired or within the period to be early rotated. diff --git a/examples/resources/grafana_service_account_rotating_token/resource.tf b/examples/resources/grafana_service_account_rotating_token/resource.tf new file mode 100644 index 000000000..e5d5e1c20 --- /dev/null +++ b/examples/resources/grafana_service_account_rotating_token/resource.tf @@ -0,0 +1,16 @@ +resource "grafana_service_account" "test" { + name = "test-service-account" + role = "Viewer" +} + +resource "grafana_service_account_rotating_token" "foo" { + name_prefix = "key_foo" + service_account_id = grafana_service_account.test.id + seconds_to_live = 7776000 # 3 months + early_rotation_window_seconds = 604800 # 1 week +} + +output "service_account_token_foo_key" { + value = grafana_service_account_rotating_token.foo.key + sensitive = true +} diff --git a/internal/resources/cloud/resource_cloud_access_policy_rotating_token.go b/internal/resources/cloud/resource_cloud_access_policy_rotating_token.go index 03edf7b5e..ce4597691 100644 --- a/internal/resources/cloud/resource_cloud_access_policy_rotating_token.go +++ b/internal/resources/cloud/resource_cloud_access_policy_rotating_token.go @@ -57,7 +57,7 @@ This is similar to the grafana_cloud_access_policy_token resource, but it repres } if earlyRotationWindow > expireAfter { - return fmt.Errorf("`early_rotation_window` cannot be bigger than `expire_after`") + return fmt.Errorf("`early_rotation_window` cannot be greater than `expire_after`") } // We need to use GetChange() to get `expires_at` from the state because Get() omits computed values diff --git a/internal/resources/cloud/resource_cloud_access_policy_rotating_token_test.go b/internal/resources/cloud/resource_cloud_access_policy_rotating_token_test.go index dbf5475ac..fc1334160 100644 --- a/internal/resources/cloud/resource_cloud_access_policy_rotating_token_test.go +++ b/internal/resources/cloud/resource_cloud_access_policy_rotating_token_test.go @@ -100,12 +100,12 @@ func TestResourceAccessPolicyRotatingToken_Basic(t *testing.T) { PlanOnly: true, ExpectError: regexp.MustCompile("`early_rotation_window` must be 0 or a positive duration string"), }, - // Test that early_rotation_window cannot be bigger than rotate_after + // Test that early_rotation_window cannot be greater than rotate_after { PreConfig: setTestTime(currentStaticTime.Format(time.RFC3339)), Config: testAccCloudAccessPolicyRotatingTokenConfigBasic(accessPolicyName, "", "prod-us-east-0", namePrefix, "1h", "1h10m", true), PlanOnly: true, - ExpectError: regexp.MustCompile("`early_rotation_window` cannot be bigger than `expire_after`"), + ExpectError: regexp.MustCompile("`early_rotation_window` cannot be greater than `expire_after`"), }, // Test that Terraform-only attributes can be updated without making API calls, by updating early_rotation_window { diff --git a/internal/resources/grafana/catalog-resource.yaml b/internal/resources/grafana/catalog-resource.yaml index 9fe7708fb..d1e331e34 100644 --- a/internal/resources/grafana/catalog-resource.yaml +++ b/internal/resources/grafana/catalog-resource.yaml @@ -378,6 +378,19 @@ spec: --- apiVersion: backstage.io/v1alpha1 kind: Component +metadata: + name: resource-grafana_service_account_rotating_token + title: grafana_service_account_rotating_token (resource) + description: | + resource `grafana_service_account_rotating_token` in Grafana Labs' Terraform Provider +spec: + subcomponentOf: component:default/terraform-provider-grafana + type: terraform-resource + owner: group:default/identity-squad + lifecycle: production +--- +apiVersion: backstage.io/v1alpha1 +kind: Component metadata: name: resource-grafana_service_account_token title: grafana_service_account_token (resource) diff --git a/internal/resources/grafana/resource_service_account_rotating_token.go b/internal/resources/grafana/resource_service_account_rotating_token.go new file mode 100644 index 000000000..6113ddca0 --- /dev/null +++ b/internal/resources/grafana/resource_service_account_rotating_token.go @@ -0,0 +1,152 @@ +package grafana + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + + "github.com/grafana/terraform-provider-grafana/v4/internal/common" +) + +func resourceServiceAccountRotatingToken() *common.Resource { + schema := &schema.Resource{ + Description: ` +**Note:** This resource is available only with Grafana 9.1+. + +* [Official documentation](https://grafana.com/docs/grafana/latest/administration/service-accounts/) +* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api)`, + + // We use the same function for Read and Update because fields are only updated in the Terraform state, + // not in Grafana, for this resource. + CreateContext: serviceAccountRotatingTokenCreate, + ReadContext: serviceAccountRotatingTokenRead, + UpdateContext: serviceAccountRotatingTokenRead, + DeleteContext: serviceAccountRotatingTokenDelete, + + CustomizeDiff: func(ctx context.Context, d *schema.ResourceDiff, meta any) error { + secondsToLive := d.Get("seconds_to_live").(int) + earlyRotationWindowSec := d.Get("early_rotation_window_seconds").(int) + + if earlyRotationWindowSec > secondsToLive { + return fmt.Errorf("`early_rotation_window_seconds` cannot be greater than `seconds_to_live`") + } + + // We need to use GetChange() to get the value from the state because Get() omits computed values that are + // not being changed. + hasExpired, _ := d.GetChange("has_expired") + if hasExpired != nil && hasExpired.(bool) { + return d.SetNew("ready_for_rotation", true) + } + + expirationState, _ := d.GetChange("expiration") + if expirationState != nil && expirationState.(string) != "" { + expiration, err := time.Parse(time.RFC3339, expirationState.(string)) + if err != nil { + return fmt.Errorf("could not parse 'expiration' while calculating custom diff: %w", err) + } + if ServiceAccountRotatingTokenNow().After(expiration.Add(-1 * time.Duration(earlyRotationWindowSec) * time.Second)) { + return d.SetNew("ready_for_rotation", true) + } + } + + return nil + }, + + Schema: serviceAccountTokenResourceWithCustomSchema(map[string]*schema.Schema{ + "name_prefix": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Prefix for the name of the service account tokens created by this resource. " + + "The actual name will be stored in the computed field `name`, which will be in the format " + + "`-`.", + }, + "seconds_to_live": { + Type: schema.TypeInt, + Required: true, + ForceNew: true, + ValidateFunc: validation.IntAtLeast(0), + Description: "The token expiration in seconds.", + }, + "early_rotation_window_seconds": { + Type: schema.TypeInt, + Required: true, + ValidateFunc: validation.IntAtLeast(0), + Description: "Duration of the time window before expiring where the token can be rotated, in seconds.", + }, + "delete_on_destroy": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Deletes the service account token in Grafana when the resource " + + "is destroyed in Terraform, instead of leaving it to expire at its `expiration` " + + "time. Use it with `lifecycle { create_before_destroy = true }` to make sure " + + "that the new token is created before the old one is deleted.", + }, + // Computed + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the service account token. It will start with `-` and will have " + + "characters appended to it to make the name unique.", + }, + "ready_for_rotation": { + Type: schema.TypeBool, + Computed: true, + ForceNew: true, + Description: "Signals that the service account token is expired or " + + "within the period to be early rotated.", + }, + }), + } + + return common.NewLegacySDKResource( + common.CategoryGrafanaOSS, + "grafana_service_account_rotating_token", + nil, + schema, + ) +} + +func serviceAccountRotatingTokenCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + namePrefix := d.Get("name_prefix").(string) + ttl := d.Get("seconds_to_live").(int) + + expiration := ServiceAccountRotatingTokenNow().Add(time.Duration(ttl) * time.Second) + name := fmt.Sprintf("%s-%d", namePrefix, expiration.Unix()) + + err := serviceAccountTokenCreateHelper(ctx, d, m, name) + if err != nil { + return diag.FromErr(err) + } + + // Fill the true resource's state by performing a read + return serviceAccountRotatingTokenRead(ctx, d, m) +} + +func serviceAccountRotatingTokenRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + return serviceAccountTokenRead(ctx, d, m) +} + +func serviceAccountRotatingTokenDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + if !d.Get("delete_on_destroy").(bool) { + return diag.Diagnostics{ + diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Rotating tokens do not get deleted by default.", + Detail: "The Service Account token will not be deleted and will expire automatically at its expiration time. " + + "If it does not have an expiration, it will need to be deleted manually. To change this behaviour " + + "enable `delete_on_destroy`.", + }, + } + } + return serviceAccountTokenDelete(ctx, d, m) +} + +// ServiceAccountRotatingTokenNow returns the current time. +// It can be overridden in tests to provide a different time. +var ServiceAccountRotatingTokenNow = time.Now diff --git a/internal/resources/grafana/resource_service_account_rotating_token_test.go b/internal/resources/grafana/resource_service_account_rotating_token_test.go new file mode 100644 index 000000000..2ecbfe04e --- /dev/null +++ b/internal/resources/grafana/resource_service_account_rotating_token_test.go @@ -0,0 +1,262 @@ +package grafana_test + +import ( + "fmt" + "regexp" + "testing" + "time" + + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/grafana/terraform-provider-grafana/v4/internal/common" + "github.com/grafana/terraform-provider-grafana/v4/internal/resources/grafana" + + "github.com/grafana/terraform-provider-grafana/v4/internal/testutils" +) + +func TestAccServiceAccountRotatingToken_basic(t *testing.T) { + testutils.CheckOSSTestsEnabled(t, ">=9.1.0") + + oldNow := grafana.ServiceAccountRotatingTokenNow + currentStaticTime := time.Now().UTC() + + namePrefix := "test-rotating-sa-token-terraform-" + acctest.RandString(10) + var sa models.ServiceAccountDTO + var token models.TokenDTO + var tokenAfterRotation models.TokenDTO + + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, + CheckDestroy: resource.ComposeTestCheckFunc( + serviceAccountCheckExists.destroyed(&sa, &models.OrgDetailsDTO{ID: sa.OrgID}), + testServiceAccountTokenCheckDestroy(&sa, &token), + ), + Steps: []resource.TestStep{ + { + PreConfig: setTestServiceAccountRotatingTokenTime(currentStaticTime.Format(time.RFC3339)), + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3600, 600, false), + Check: resource.ComposeTestCheckFunc( + // We can't adhere to the interface of checkExistsHelper for SA tokens because we do not have + // an API that returns a token given its ID. Hence, we need to create special helpers for + // this particular scenario. + serviceAccountCheckExists.exists("grafana_service_account.test", &sa), + checkServiceAccountTokenExists(&sa, testServiceAccountRotatingTokenComputedName(namePrefix, 3600), &token), + resource.TestCheckResourceAttr("grafana_service_account.test", "name", namePrefix), + resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Editor"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "name_prefix", namePrefix), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "name", testServiceAccountRotatingTokenComputedName(namePrefix, 3600)), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "seconds_to_live", "3600"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "early_rotation_window_seconds", "600"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "delete_on_destroy", "false"), + resource.TestCheckResourceAttrSet("grafana_service_account_rotating_token.test", "expiration"), + ), + }, + // Test that rotation is not triggered before time by running a plan + { + PreConfig: func() { + setTestServiceAccountRotatingTokenTime(currentStaticTime.Add(5 * time.Second).Format(time.RFC3339))() + }, + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3600, 600, false), + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + // Test that rotation is triggered if running a plan within the early rotation window + { + PreConfig: func() { + setTestServiceAccountRotatingTokenTime(time.Time(token.Expiration).Add(-599 * time.Second).Format(time.RFC3339))() + }, + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3600, 600, false), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + // Test that rotation is triggered if running a plan after the token's expiration date + { + PreConfig: func() { + setTestServiceAccountRotatingTokenTime(time.Time(token.Expiration).Add(10 * time.Second).Format(time.RFC3339))() + }, + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3600, 600, false), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + // Test that early_rotation_window cannot be greater than rotate_after + { + PreConfig: setTestServiceAccountRotatingTokenTime(currentStaticTime.Format(time.RFC3339)), + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 10, 20, false), + PlanOnly: true, + ExpectError: regexp.MustCompile("`early_rotation_window_seconds` cannot be greater than `seconds_to_live`"), + }, + // Test that Terraform-only attributes can be updated without re-creating the token, by updating early_rotation_window and delete_on_destroy + { + PreConfig: setTestServiceAccountRotatingTokenTime(currentStaticTime.Format(time.RFC3339)), + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3600, 700, true), + Check: resource.ComposeTestCheckFunc( + func(s *terraform.State) error { + beforeID := token.ID + err := checkServiceAccountTokenExists(&sa, testServiceAccountRotatingTokenComputedName(namePrefix, 3600), &token)(s) + if err != nil { + return err + } + if beforeID != token.ID { + return fmt.Errorf("expected token not to be re-created when updating Terraform-only attributes") + } + return nil + }, + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "seconds_to_live", "3600"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "early_rotation_window_seconds", "700"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "delete_on_destroy", "true"), + ), + }, + // Test seconds_to_live change should force recreation + { + PreConfig: setTestServiceAccountRotatingTokenTime(currentStaticTime.Format(time.RFC3339)), + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3700, 700, false), + Check: resource.ComposeTestCheckFunc( + checkServiceAccountTokenExists(&sa, testServiceAccountRotatingTokenComputedName(namePrefix, 3700), &tokenAfterRotation), + resource.TestCheckResourceAttr("grafana_service_account.test", "name", namePrefix), + resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Editor"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "name_prefix", namePrefix), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "name", testServiceAccountRotatingTokenComputedName(namePrefix, 3700)), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "seconds_to_live", "3700"), + resource.TestCheckResourceAttr("grafana_service_account_rotating_token.test", "early_rotation_window_seconds", "700"), + resource.TestCheckResourceAttrSet("grafana_service_account_rotating_token.test", "expiration"), + + func(s *terraform.State) error { + if token.Name == tokenAfterRotation.Name { + return fmt.Errorf("expected token to be recreated, but Name remained the same: %s", token.Name) + } + if token.ID == 0 || tokenAfterRotation.ID == 0 { + return fmt.Errorf("expected token to be recreated, but ID is empty") + } + if token.ID == tokenAfterRotation.ID { + return fmt.Errorf("expected token to be recreated, but ID remained the same: %d", token.ID) + } + return nil + }, + ), + }, + // Make sure token exists + { + Config: testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3700, 700, false), + Check: checkServiceAccountTokenExists(&sa, testServiceAccountRotatingTokenComputedName(namePrefix, 3700), &token), + }, + // Test that destroy does not actually delete the token (it should only show a warning instead) + { + Config: testutils.WithoutResource(t, testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3700, 700, false), "grafana_service_account_rotating_token.test"), + Check: resource.ComposeTestCheckFunc( + serviceAccountCheckExists.exists("grafana_service_account.test", &sa), + func(s *terraform.State) error { + // Check that the token resource is no longer in the state + _, exists := s.RootModule().Resources["grafana_service_account_rotating_token.test"] + if exists { + return fmt.Errorf("expected token resource to be removed from state after destroy, but it still exists") + } + + // Verify that the token still exists in Grafana + var tokenFromGrafana models.TokenDTO + err := checkServiceAccountTokenExists(&sa, token.Name, &tokenFromGrafana)(s) + if err != nil { + return err + } + if tokenFromGrafana.ID == 0 || token.ID == 0 { + return fmt.Errorf("expected token to still exist after destroy, but API response is empty") + } + if token.ID != tokenFromGrafana.ID { + return fmt.Errorf("expected token IDs to be the same (%d) (%d)", token.ID, token.ID) + } + if token.Expiration.IsZero() { + return fmt.Errorf("expected token to have an expiration date, but it does not have one") + } + if token.HasExpired { + return fmt.Errorf("expected token not to be expired, but it expired on %s", token.Expiration) + } + + return nil + }, + ), + }, + // Test that the token exists and can be manually deleted through the API + { + Config: testutils.WithoutResource(t, testAccServiceAccountRotatingTokenConfig(namePrefix, "Editor", 3700, 700, false), "grafana_service_account_rotating_token.test"), + PreConfig: func() { + client := testutils.Provider.Meta().(*common.Client).GrafanaAPI.WithOrgID(sa.OrgID) + _, err := client.ServiceAccounts.DeleteToken(token.ID, sa.ID) + if err != nil { + t.Fatalf("error deleting service account token: %s", err) + } + }, + }, + // Create new token to test deletion, setting `delete_on_destroy = true` + { + PreConfig: setTestServiceAccountRotatingTokenTime(currentStaticTime.Format(time.RFC3339)), + Config: testAccServiceAccountRotatingTokenConfig(namePrefix+"-to-be-deleted", "Editor", 3600, 600, true), + Check: resource.ComposeTestCheckFunc( + serviceAccountCheckExists.exists("grafana_service_account.test", &sa), + checkServiceAccountTokenExists(&sa, testServiceAccountRotatingTokenComputedName(namePrefix+"-to-be-deleted", 3600), &token), + ), + }, + // Test that destroy deletes the token both in Terraform and in Grafana + { + Config: testutils.WithoutResource(t, testAccServiceAccountRotatingTokenConfig(namePrefix+"-to-be-deleted", "Editor", 3600, 600, true), "grafana_service_account_rotating_token.test"), + Check: resource.ComposeTestCheckFunc( + serviceAccountCheckExists.exists("grafana_service_account.test", &sa), + func(s *terraform.State) error { + // Check that the token resource is no longer in the state + _, exists := s.RootModule().Resources["grafana_service_account_rotating_token.test"] + if exists { + return fmt.Errorf("expected token resource to be removed from state after destroy, but it still exists") + } + + // Verify that the token is deleted in Grafana too + var tokenFromGrafana models.TokenDTO + err := checkServiceAccountTokenExists(&sa, token.Name, &tokenFromGrafana)(s) + if err == nil { + return fmt.Errorf("expected token with name '%s' to have been deleted in Grafana, but it still exists", tokenFromGrafana.Name) + } + + return nil + }, + ), + }, + }, + }) + grafana.ServiceAccountRotatingTokenNow = oldNow +} + +func testAccServiceAccountRotatingTokenConfig(namePrefix, role string, secondsToLive, earlyRotationWindowSeconds int, deleteOnDestroy bool) string { + var deleteStr string + if deleteOnDestroy { + deleteStr = `delete_on_destroy = true` + } + + return fmt.Sprintf(` +resource "grafana_service_account" "test" { + name = "%[1]s" + role = "%[2]s" +} + +resource "grafana_service_account_rotating_token" "test" { + name_prefix = "%[1]s" + service_account_id = grafana_service_account.test.id + seconds_to_live = %[3]d + early_rotation_window_seconds = %[4]d + %[5]s +} +`, namePrefix, role, secondsToLive, earlyRotationWindowSeconds, deleteStr) +} + +func setTestServiceAccountRotatingTokenTime(t string) func() { + return func() { + grafana.ServiceAccountRotatingTokenNow = func() time.Time { + parsedT, _ := time.Parse(time.RFC3339, t) + return parsedT + } + } +} + +func testServiceAccountRotatingTokenComputedName(namePrefix string, secondsToLive int) string { + expiration := grafana.ServiceAccountRotatingTokenNow().Add(time.Duration(secondsToLive) * time.Second) + return fmt.Sprintf("%s-%d", namePrefix, expiration.Unix()) +} diff --git a/internal/resources/grafana/resource_service_account_token.go b/internal/resources/grafana/resource_service_account_token.go index 01e9a58cf..fe164800c 100644 --- a/internal/resources/grafana/resource_service_account_token.go +++ b/internal/resources/grafana/resource_service_account_token.go @@ -6,9 +6,10 @@ import ( "github.com/grafana/grafana-openapi-client-go/client/service_accounts" "github.com/grafana/grafana-openapi-client-go/models" - "github.com/grafana/terraform-provider-grafana/v4/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/grafana/terraform-provider-grafana/v4/internal/common" ) func resourceServiceAccountToken() *common.Resource { @@ -23,42 +24,20 @@ func resourceServiceAccountToken() *common.Resource { ReadContext: serviceAccountTokenRead, DeleteContext: serviceAccountTokenDelete, - Schema: map[string]*schema.Schema{ + Schema: serviceAccountTokenResourceWithCustomSchema(map[string]*schema.Schema{ "name": { Type: schema.TypeString, Required: true, ForceNew: true, Description: "The name of the service account token.", }, - "service_account_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - Description: "The ID of the service account to which the token belongs.", - }, "seconds_to_live": { Type: schema.TypeInt, Optional: true, ForceNew: true, Description: "The key expiration in seconds. It is optional. If it is a positive number an expiration date for the key is set. If it is null, zero or is omitted completely (unless `api_key_max_seconds_to_live` configuration option is set) the key will never expire.", }, - "key": { - Type: schema.TypeString, - Computed: true, - Sensitive: true, - Description: "The key of the service account token.", - }, - "expiration": { - Type: schema.TypeString, - Computed: true, - Description: "The expiration date of the service account token.", - }, - "has_expired": { - Type: schema.TypeBool, - Computed: true, - Description: "The status of the service account token.", - }, - }, + }), } return common.NewLegacySDKResource( @@ -68,16 +47,25 @@ func resourceServiceAccountToken() *common.Resource { schema, ) } - func serviceAccountTokenCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + name := d.Get("name").(string) + err := serviceAccountTokenCreateHelper(ctx, d, m, name) + if err != nil { + return diag.FromErr(err) + } + + // Fill the true resource's state by performing a read + return serviceAccountTokenRead(ctx, d, m) +} + +func serviceAccountTokenCreateHelper(ctx context.Context, d *schema.ResourceData, m any, name string) error { orgID, serviceAccountIDStr := SplitOrgResourceID(d.Get("service_account_id").(string)) c := m.(*common.Client).GrafanaAPI.Clone().WithOrgID(orgID) serviceAccountID, err := strconv.ParseInt(serviceAccountIDStr, 10, 64) if err != nil { - return diag.FromErr(err) + return err } - name := d.Get("name").(string) ttl := d.Get("seconds_to_live").(int) request := models.AddServiceAccountTokenCommand{ @@ -87,18 +75,16 @@ func serviceAccountTokenCreate(ctx context.Context, d *schema.ResourceData, m an params := service_accounts.NewCreateTokenParams().WithServiceAccountID(serviceAccountID).WithBody(&request) response, err := c.ServiceAccounts.CreateToken(params) if err != nil { - return diag.FromErr(err) + return err } token := response.Payload d.SetId(strconv.FormatInt(token.ID, 10)) - err = d.Set("key", token.Key) - if err != nil { - return diag.FromErr(err) + if err = d.Set("key", token.Key); err != nil { + return err } - // Fill the true resource's state by performing a read - return serviceAccountTokenRead(ctx, d, m) + return nil } func serviceAccountTokenRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { @@ -157,3 +143,38 @@ func serviceAccountTokenDelete(ctx context.Context, d *schema.ResourceData, m an return diag.FromErr(err) } + +// serviceAccountTokenResourceWithCustomSchema returns a map that has the fields common to all token-related resources, like tokens +// and token rotations, plus the specified custom fields. +func serviceAccountTokenResourceWithCustomSchema(customFields map[string]*schema.Schema) map[string]*schema.Schema { + // preset shared common fields + fields := map[string]*schema.Schema{ + "service_account_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the service account to which the token belongs.", + }, + // Computed + "key": { + Type: schema.TypeString, + Computed: true, + Sensitive: true, + Description: "The key of the service account token.", + }, + "expiration": { + Type: schema.TypeString, + Computed: true, + Description: "The expiration date of the service account token.", + }, + "has_expired": { + Type: schema.TypeBool, + Computed: true, + Description: "The status of the service account token.", + }, + } + for k, v := range customFields { + fields[k] = v + } + return fields +} diff --git a/internal/resources/grafana/resource_service_account_token_test.go b/internal/resources/grafana/resource_service_account_token_test.go index cdda0d858..4205ca296 100644 --- a/internal/resources/grafana/resource_service_account_token_test.go +++ b/internal/resources/grafana/resource_service_account_token_test.go @@ -5,10 +5,12 @@ import ( "testing" "github.com/grafana/grafana-openapi-client-go/models" - "github.com/grafana/terraform-provider-grafana/v4/internal/testutils" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + + "github.com/grafana/terraform-provider-grafana/v4/internal/common" + "github.com/grafana/terraform-provider-grafana/v4/internal/testutils" ) func TestAccServiceAccountToken_basic(t *testing.T) { @@ -16,6 +18,7 @@ func TestAccServiceAccountToken_basic(t *testing.T) { name := acctest.RandString(10) var sa models.ServiceAccountDTO + var token models.TokenDTO resource.ParallelTest(t, resource.TestCase{ ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, @@ -25,7 +28,7 @@ func TestAccServiceAccountToken_basic(t *testing.T) { Config: testAccServiceAccountTokenConfig(name, "Editor", 0, false), Check: resource.ComposeTestCheckFunc( serviceAccountCheckExists.exists("grafana_service_account.test", &sa), - checkServiceAccountTokens(&sa, []string{name}), + checkServiceAccountTokenExists(&sa, name, &token), resource.TestCheckResourceAttr("grafana_service_account.test", "name", name), resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Editor"), resource.TestCheckResourceAttr("grafana_service_account_token.test", "name", name), @@ -36,7 +39,7 @@ func TestAccServiceAccountToken_basic(t *testing.T) { Config: testAccServiceAccountTokenConfig(name+"-updated", "Viewer", 300, false), Check: resource.ComposeTestCheckFunc( serviceAccountCheckExists.exists("grafana_service_account.test", &sa), - checkServiceAccountTokens(&sa, []string{name + "-updated"}), + checkServiceAccountTokenExists(&sa, name+"-updated", &token), resource.TestCheckResourceAttr("grafana_service_account.test", "name", name+"-updated"), resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Viewer"), resource.TestCheckResourceAttr("grafana_service_account_token.test", "name", name+"-updated"), @@ -48,7 +51,6 @@ func TestAccServiceAccountToken_basic(t *testing.T) { Config: testutils.WithoutResource(t, testAccServiceAccountTokenConfig(name+"-updated", "Viewer", 300, false), "grafana_service_account_token.test"), Check: resource.ComposeTestCheckFunc( serviceAccountCheckExists.exists("grafana_service_account.test", &sa), - checkServiceAccountTokens(&sa, []string{}), ), }, }, @@ -61,6 +63,7 @@ func TestAccServiceAccountToken_inOrg(t *testing.T) { name := acctest.RandString(10) var org models.OrgDetailsDTO var sa models.ServiceAccountDTO + var token models.TokenDTO resource.ParallelTest(t, resource.TestCase{ ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories, @@ -70,7 +73,7 @@ func TestAccServiceAccountToken_inOrg(t *testing.T) { Config: testAccServiceAccountTokenConfig(name, "Editor", 0, true), Check: resource.ComposeTestCheckFunc( serviceAccountCheckExists.exists("grafana_service_account.test", &sa), - checkServiceAccountTokens(&sa, []string{name}), + checkServiceAccountTokenExists(&sa, name, &token), resource.TestCheckResourceAttr("grafana_service_account.test", "name", name), resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Editor"), resource.TestCheckResourceAttr("grafana_service_account_token.test", "name", name), @@ -86,7 +89,7 @@ func TestAccServiceAccountToken_inOrg(t *testing.T) { Config: testAccServiceAccountTokenConfig(name+"-updated", "Viewer", 300, true), Check: resource.ComposeTestCheckFunc( serviceAccountCheckExists.exists("grafana_service_account.test", &sa), - checkServiceAccountTokens(&sa, []string{name + "-updated"}), + checkServiceAccountTokenExists(&sa, name+"-updated", &token), resource.TestCheckResourceAttr("grafana_service_account.test", "name", name+"-updated"), resource.TestCheckResourceAttr("grafana_service_account.test", "role", "Viewer"), resource.TestCheckResourceAttr("grafana_service_account_token.test", "name", name+"-updated"), @@ -103,38 +106,27 @@ func TestAccServiceAccountToken_inOrg(t *testing.T) { Config: testutils.WithoutResource(t, testAccServiceAccountTokenConfig(name+"-updated", "Viewer", 300, true), "grafana_service_account_token.test"), Check: resource.ComposeTestCheckFunc( serviceAccountCheckExists.exists("grafana_service_account.test", &sa), - checkServiceAccountTokens(&sa, []string{}), ), }, }, }) } -func checkServiceAccountTokens(sa *models.ServiceAccountDTO, expectNames []string) resource.TestCheckFunc { +func checkServiceAccountTokenExists(sa *models.ServiceAccountDTO, tokenName string, t *models.TokenDTO) resource.TestCheckFunc { return func(s *terraform.State) error { client := grafanaTestClient().WithOrgID(sa.OrgID) resp, err := client.ServiceAccounts.ListTokens(sa.ID) if err != nil { return err } - tokens := resp.Payload - if len(tokens) != len(expectNames) { - return fmt.Errorf("Expected %d tokens, got %d", len(expectNames), len(tokens)) - } - for _, name := range expectNames { - found := false - for _, token := range tokens { - if token.Name == name { - found = true - break - } - } - if !found { - return fmt.Errorf("Expected token %s not found", name) + for _, token := range resp.Payload { + if token.Name == tokenName { + *t = *token + return nil } } - return nil + return fmt.Errorf("expected token %s not found", tokenName) } } @@ -170,3 +162,27 @@ resource "grafana_service_account_token" "test" { } `, name, role, secondsToLiveAttr, orgIDAttr) } + +func testServiceAccountTokenCheckDestroy(sa *models.ServiceAccountDTO, t *models.TokenDTO) resource.TestCheckFunc { + return func(s *terraform.State) error { + if sa == nil || sa.ID == 0 { + return nil + } + if t == nil || t.ID == 0 { + return nil + } + client := testutils.Provider.Meta().(*common.Client).GrafanaAPI.WithOrgID(sa.OrgID) + resp, err := client.ServiceAccounts.ListTokens(sa.ID) + if err != nil { + return err + } + + for _, key := range resp.Payload { + if t.ID == key.ID { + return fmt.Errorf("grafana service account token `%d` with name `%s` still exists after destroy", t.ID, t.Name) + } + } + + return nil + } +} diff --git a/internal/resources/grafana/resources.go b/internal/resources/grafana/resources.go index b33a2ed5e..aa532b058 100644 --- a/internal/resources/grafana/resources.go +++ b/internal/resources/grafana/resources.go @@ -4,9 +4,10 @@ import ( "context" "fmt" - "github.com/grafana/terraform-provider-grafana/v4/internal/common" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/grafana/terraform-provider-grafana/v4/internal/common" ) func grafanaClientResourceValidation(d *schema.ResourceData, m any) error { @@ -133,6 +134,7 @@ var Resources = addValidationToResources( resourceTeam(), resourceTeamExternalGroup(), resourceServiceAccountToken(), + resourceServiceAccountRotatingToken(), resourceServiceAccount(), resourceServiceAccountPermission(), resourceSSOSettings(), diff --git a/pkg/generate/postprocessing/replace_references.go b/pkg/generate/postprocessing/replace_references.go index a205d8e36..0e0c4bdba 100644 --- a/pkg/generate/postprocessing/replace_references.go +++ b/pkg/generate/postprocessing/replace_references.go @@ -136,6 +136,7 @@ var knownReferences = []string{ "grafana_service_account_permission_item.team=grafana_team.id", "grafana_service_account_permission_item.url=grafana_cloud_stack.url", "grafana_service_account_permission_item.user=grafana_user.id", + "grafana_service_account_rotating_token.service_account_id=grafana_service_account.id", "grafana_service_account_token.service_account_id=grafana_service_account.id", "grafana_slo.folder_uid=grafana_folder.uid", "grafana_synthetic_monitoring_check_alerts.check_id=grafana_synthetic_monitoring_check.id",