Skip to content

Commit fe54d81

Browse files
feat(cloud-stack-service-accounts): Add rotating token resource (#2445)
1 parent 0c87be7 commit fe54d81

File tree

11 files changed

+574
-66
lines changed

11 files changed

+574
-66
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "grafana_cloud_stack_service_account_rotating_token Resource - terraform-provider-grafana"
4+
subcategory: "Cloud"
5+
description: |-
6+
Manages and rotates service account tokens of a Grafana Cloud stack using the Cloud API
7+
This can be used to bootstrap a management service account token for a new stack
8+
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
9+
Required access policy scopes:
10+
stack-service-accounts:write
11+
---
12+
13+
# grafana_cloud_stack_service_account_rotating_token (Resource)
14+
15+
Manages and rotates service account tokens of a Grafana Cloud stack using the Cloud API
16+
This can be used to bootstrap a management service account token for a new stack
17+
18+
* [Official documentation](https://grafana.com/docs/grafana/latest/administration/service-accounts/)
19+
* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api)
20+
21+
Required access policy scopes:
22+
23+
* stack-service-accounts:write
24+
25+
## Example Usage
26+
27+
```terraform
28+
resource "grafana_cloud_stack_service_account" "cloud_sa" {
29+
stack_slug = "<your stack slug>"
30+
31+
name = "cloud service account"
32+
role = "Admin"
33+
is_disabled = false
34+
}
35+
36+
resource "grafana_cloud_stack_service_account_rotating_token" "foo" {
37+
stack_slug = "<your stack slug>"
38+
39+
name_prefix = "key_foo"
40+
service_account_id = grafana_cloud_stack_service_account.cloud_sa.id
41+
seconds_to_live = 7776000 # 3 months
42+
early_rotation_window_seconds = 604800 # 1 week
43+
}
44+
45+
output "service_account_token_foo_key" {
46+
value = grafana_cloud_stack_service_account_token.foo.key
47+
sensitive = true
48+
}
49+
```
50+
51+
<!-- schema generated by tfplugindocs -->
52+
## Schema
53+
54+
### Required
55+
56+
- `early_rotation_window_seconds` (Number) Duration of the time window before expiring where the token can be rotated, in seconds.
57+
- `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 `<name_prefix>-<additional_characters>`.
58+
- `seconds_to_live` (Number) The token expiration in seconds.
59+
- `service_account_id` (String) The ID of the service account to which the token belongs.
60+
- `stack_slug` (String)
61+
62+
### Optional
63+
64+
- `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`.
65+
66+
### Read-Only
67+
68+
- `expiration` (String) The expiration date of the service account token.
69+
- `has_expired` (Boolean) The status of the service account token.
70+
- `id` (String) The ID of this resource.
71+
- `key` (String, Sensitive) The key of the service account token.
72+
- `name` (String) The name of the service account token. It will start with `<name_prefix>-` and will have characters appended to it to make the name unique.
73+
- `ready_for_rotation` (Boolean) Signals that the service account token is expired or within the period to be early rotated.

docs/resources/cloud_stack_service_account_token.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ resource "grafana_cloud_stack_service_account" "cloud_sa" {
3434
}
3535
3636
resource "grafana_cloud_stack_service_account_token" "foo" {
37+
stack_slug = "<your stack slug>"
38+
3739
name = "key_foo"
3840
service_account_id = grafana_cloud_stack_service_account.cloud_sa.id
3941
}
@@ -49,17 +51,17 @@ output "service_account_token_foo_key" {
4951

5052
### Required
5153

52-
- `name` (String)
53-
- `service_account_id` (String)
54+
- `name` (String) The name of the service account token.
55+
- `service_account_id` (String) The ID of the service account to which the token belongs.
5456
- `stack_slug` (String)
5557

5658
### Optional
5759

58-
- `seconds_to_live` (Number)
60+
- `seconds_to_live` (Number) 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.
5961

6062
### Read-Only
6163

62-
- `expiration` (String)
63-
- `has_expired` (Boolean)
64+
- `expiration` (String) The expiration date of the service account token.
65+
- `has_expired` (Boolean) The status of the service account token.
6466
- `id` (String) The ID of this resource.
65-
- `key` (String, Sensitive)
67+
- `key` (String, Sensitive) The key of the service account token.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
resource "grafana_cloud_stack_service_account" "cloud_sa" {
2+
stack_slug = "<your stack slug>"
3+
4+
name = "cloud service account"
5+
role = "Admin"
6+
is_disabled = false
7+
}
8+
9+
resource "grafana_cloud_stack_service_account_rotating_token" "foo" {
10+
stack_slug = "<your stack slug>"
11+
12+
name_prefix = "key_foo"
13+
service_account_id = grafana_cloud_stack_service_account.cloud_sa.id
14+
seconds_to_live = 7776000 # 3 months
15+
early_rotation_window_seconds = 604800 # 1 week
16+
}
17+
18+
output "service_account_token_foo_key" {
19+
value = grafana_cloud_stack_service_account_token.foo.key
20+
sensitive = true
21+
}

examples/resources/grafana_cloud_stack_service_account_token/resource.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ resource "grafana_cloud_stack_service_account" "cloud_sa" {
77
}
88

99
resource "grafana_cloud_stack_service_account_token" "foo" {
10+
stack_slug = "<your stack slug>"
11+
1012
name = "key_foo"
1113
service_account_id = grafana_cloud_stack_service_account.cloud_sa.id
1214
}

internal/resources/cloud/catalog-resource.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ spec:
118118
---
119119
apiVersion: backstage.io/v1alpha1
120120
kind: Component
121+
metadata:
122+
name: resource-grafana_cloud_stack_service_account_rotating_token
123+
title: grafana_cloud_stack_service_account_rotating_token (resource)
124+
description: |
125+
resource `grafana_cloud_stack_service_account_rotating_token` in Grafana Labs' Terraform Provider
126+
spec:
127+
subcomponentOf: component:default/terraform-provider-grafana
128+
type: terraform-resource
129+
owner: group:default/identity-squad
130+
lifecycle: production
131+
---
132+
apiVersion: backstage.io/v1alpha1
133+
kind: Component
121134
metadata:
122135
name: resource-grafana_cloud_stack_service_account_token
123136
title: grafana_cloud_stack_service_account_token (resource)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package cloud
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/grafana/grafana-com-public-clients/go/gcom"
9+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
11+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
12+
13+
"github.com/grafana/terraform-provider-grafana/v4/internal/common"
14+
)
15+
16+
func resourceStackServiceAccountRotatingToken() *common.Resource {
17+
schema := &schema.Resource{
18+
Description: `
19+
Manages and rotates service account tokens of a Grafana Cloud stack using the Cloud API
20+
This can be used to bootstrap a management service account token for a new stack
21+
22+
* [Official documentation](https://grafana.com/docs/grafana/latest/administration/service-accounts/)
23+
* [HTTP API](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api)
24+
25+
Required access policy scopes:
26+
27+
* stack-service-accounts:write
28+
`,
29+
30+
// We use the same function for Read and Update because fields are only updated in the Terraform state,
31+
// not in Grafana, for this resource.
32+
CreateContext: withClient[schema.CreateContextFunc](stackServiceAccountRotatingTokenCreate),
33+
ReadContext: withClient[schema.ReadContextFunc](stackServiceAccountRotatingTokenRead),
34+
UpdateContext: withClient[schema.UpdateContextFunc](stackServiceAccountRotatingTokenRead),
35+
DeleteContext: withClient[schema.DeleteContextFunc](stackServiceAccountRotatingTokenDelete),
36+
37+
CustomizeDiff: func(ctx context.Context, d *schema.ResourceDiff, meta any) error {
38+
secondsToLive := d.Get("seconds_to_live").(int)
39+
earlyRotationWindowSec := d.Get("early_rotation_window_seconds").(int)
40+
41+
if earlyRotationWindowSec > secondsToLive {
42+
return fmt.Errorf("`early_rotation_window_seconds` cannot be greater than `seconds_to_live`")
43+
}
44+
45+
// We need to use GetChange() to get the value from the state because Get() omits computed values that are
46+
// not being changed.
47+
hasExpired, _ := d.GetChange("has_expired")
48+
if hasExpired != nil && hasExpired.(bool) {
49+
return d.SetNew("ready_for_rotation", true)
50+
}
51+
52+
expirationState, _ := d.GetChange("expiration")
53+
if expirationState != nil && expirationState.(string) != "" {
54+
// We save the expiration time in Golang's native layout when we read the resource, so we
55+
// need to use that same layout here. We should ideally switch to a standard format like
56+
// RFC3339.
57+
expiration, err := time.Parse("2006-01-02 15:04:05 -0700 MST", expirationState.(string))
58+
if err != nil {
59+
return fmt.Errorf("could not parse 'expiration' while calculating custom diff: %w", err)
60+
}
61+
if Now().After(expiration.Add(-1 * time.Duration(earlyRotationWindowSec) * time.Second)) {
62+
return d.SetNew("ready_for_rotation", true)
63+
}
64+
}
65+
66+
return nil
67+
},
68+
69+
Schema: stackServiceAccountTokenResourceWithCustomSchema(map[string]*schema.Schema{
70+
"name_prefix": {
71+
Type: schema.TypeString,
72+
Required: true,
73+
ForceNew: true,
74+
Description: "Prefix for the name of the service account tokens created by this resource. " +
75+
"The actual name will be stored in the computed field `name`, which will be in the format " +
76+
"`<name_prefix>-<additional_characters>`.",
77+
},
78+
"seconds_to_live": {
79+
Type: schema.TypeInt,
80+
Required: true,
81+
ForceNew: true,
82+
ValidateFunc: validation.IntAtLeast(0),
83+
Description: "The token expiration in seconds.",
84+
},
85+
"early_rotation_window_seconds": {
86+
Type: schema.TypeInt,
87+
Required: true,
88+
ValidateFunc: validation.IntAtLeast(0),
89+
Description: "Duration of the time window before expiring where the token can be rotated, in seconds.",
90+
},
91+
"delete_on_destroy": {
92+
Type: schema.TypeBool,
93+
Optional: true,
94+
Default: false,
95+
Description: "Deletes the service account token in Grafana when the resource " +
96+
"is destroyed in Terraform, instead of leaving it to expire at its `expiration` " +
97+
"time. Use it with `lifecycle { create_before_destroy = true }` to make sure " +
98+
"that the new token is created before the old one is deleted.",
99+
},
100+
// Computed
101+
"name": {
102+
Type: schema.TypeString,
103+
Computed: true,
104+
Description: "The name of the service account token. It will start with `<name_prefix>-` and will have " +
105+
"characters appended to it to make the name unique.",
106+
},
107+
"ready_for_rotation": {
108+
Type: schema.TypeBool,
109+
Computed: true,
110+
ForceNew: true,
111+
Description: "Signals that the service account token is expired or " +
112+
"within the period to be early rotated.",
113+
},
114+
}),
115+
}
116+
117+
return common.NewLegacySDKResource(
118+
common.CategoryCloud,
119+
"grafana_cloud_stack_service_account_rotating_token",
120+
nil,
121+
schema,
122+
)
123+
}
124+
125+
func stackServiceAccountRotatingTokenCreate(ctx context.Context, d *schema.ResourceData, cloudClient *gcom.APIClient) diag.Diagnostics {
126+
namePrefix := d.Get("name_prefix").(string)
127+
ttl := d.Get("seconds_to_live").(int)
128+
129+
expiration := Now().Add(time.Duration(ttl) * time.Second)
130+
name := fmt.Sprintf("%s-%d", namePrefix, expiration.Unix())
131+
132+
errDiag := stackServiceAccountTokenCreateHelper(ctx, d, cloudClient, name)
133+
if errDiag.HasError() {
134+
return errDiag
135+
}
136+
137+
// Fill the true resource's state by performing a read
138+
return stackServiceAccountRotatingTokenRead(ctx, d, cloudClient)
139+
}
140+
141+
func stackServiceAccountRotatingTokenRead(ctx context.Context, d *schema.ResourceData, cloudClient *gcom.APIClient) diag.Diagnostics {
142+
return stackServiceAccountTokenRead(ctx, d, cloudClient)
143+
}
144+
145+
func stackServiceAccountRotatingTokenDelete(ctx context.Context, d *schema.ResourceData, cloudClient *gcom.APIClient) diag.Diagnostics {
146+
if !d.Get("delete_on_destroy").(bool) {
147+
return diag.Diagnostics{
148+
diag.Diagnostic{
149+
Severity: diag.Warning,
150+
Summary: "Rotating tokens do not get deleted by default.",
151+
Detail: "The Service Account token will not be deleted and will expire automatically at its expiration time. " +
152+
"If it does not have an expiration, it will need to be deleted manually. To change this behaviour " +
153+
"enable `delete_on_destroy`.",
154+
},
155+
}
156+
}
157+
return stackServiceAccountTokenDelete(ctx, d, cloudClient)
158+
}

0 commit comments

Comments
 (0)