Skip to content

Commit 1e44520

Browse files
committed
Add support for TDE key rotation
1 parent 3a4fc2e commit 1e44520

15 files changed

+162
-35
lines changed

docs/resources/service.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ resource "clickhouse_service" "service" {
5959
- `encryption_assumed_role_identifier` (String) Custom role identifier ARN.
6060
- `encryption_key` (String) Custom encryption key ARN.
6161
- `endpoints` (Attributes) Allow to enable and configure additional endpoints (read protocols) to expose on the ClickHouse service. (see [below for nested schema](#nestedatt--endpoints))
62-
- `has_transparent_data_encryption` (Boolean) If true, the Transparent Data Encryption (TDE) feature is enabled in the service. Only supported in AWS and GCP. Requires an organization with the Enterprise plan.
6362
- `idle_scaling` (Boolean) When set to true the service is allowed to scale down to zero when idle.
6463
- `idle_timeout_minutes` (Number) Set minimum idling timeout (in minutes). Must be greater than or equal to 5 minutes. Must be set if idle_scaling is enabled.
6564
- `max_replica_memory_gb` (Number) Maximum memory of a single replica during auto-scaling in Gb. Must be a multiple of 8. `max_replica_memory_gb` x `num_replicas` (default 3) must be lower than 360 for non paid services or 720 for paid services.
@@ -73,6 +72,7 @@ resource "clickhouse_service" "service" {
7372
- `readonly` (Boolean) Indicates if this service should be read only. Only allowed for secondary services, those which share data with another service (i.e. when `warehouse_id` field is set).
7473
- `release_channel` (String) Release channel to use for this service. Either 'default' or 'fast'. Switching from 'fast' to 'default' release channel is not supported.
7574
- `tier` (String) Tier of the service: 'development', 'production'. Required for organizations using the Legacy ClickHouse Cloud Tiers, must be omitted for organizations using the new ClickHouse Cloud Tiers.
75+
- `transparent_data_encryption` (Attributes) Configuration of the Transparent Data Encryption (TDE) feature. Requires an organization with the Enterprise plan. (see [below for nested schema](#nestedatt--transparent_data_encryption))
7676
- `warehouse_id` (String) ID of the warehouse to share the data with. Must be in the same cloud and region.
7777

7878
### Read-Only
@@ -155,6 +155,18 @@ Optional:
155155
- `allowed_origins` (String) Comma separated list of domain names to be allowed cross-origin resource sharing (CORS) access to the query API. Leave this field empty to restrict access to backend servers only
156156

157157

158+
<a id="nestedatt--transparent_data_encryption"></a>
159+
### Nested Schema for `transparent_data_encryption`
160+
161+
Required:
162+
163+
- `enabled` (Boolean) If true, TDE is enabled for the service.
164+
165+
Optional:
166+
167+
- `key_id` (String) ID of the Encryption key to use for data encryption. Must be an ARN for AWS services or a Key Resource Path for GCP services.
168+
169+
158170
<a id="nestedatt--private_endpoint_config"></a>
159171
### Nested Schema for `private_endpoint_config`
160172

File renamed without changes.

examples/full/tde/aws/main.tf renamed to examples/tde/aws/main.tf

+4-18
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,6 @@ resource "clickhouse_service" "service" {
4848
}
4949
]
5050

51-
endpoints = {
52-
mysql = {
53-
enabled = true
54-
}
55-
}
56-
57-
query_api_endpoints = {
58-
api_key_ids = [
59-
data.clickhouse_api_key_id.self.id,
60-
]
61-
roles = [
62-
"sql_console_admin"
63-
]
64-
allowed_origins = null
65-
}
66-
6751
min_replica_memory_gb = 8
6852
max_replica_memory_gb = 120
6953

@@ -73,8 +57,10 @@ resource "clickhouse_service" "service" {
7357
backup_start_time = null
7458
}
7559

76-
has_transparent_data_encryption = true
77-
transparent_data_encryption_key_id = "arn:aws:kms:us-east-2:662591887723:key/a4e565f6-be36-4397-8d09-83f919f1e67f"
60+
transparent_data_encryption = {
61+
enabled = true
62+
key_id = "arn:aws:kms:us-east-2:XXXXXXXX:key/12345-6789-abcd-ef01-23456789abcde"
63+
}
7864
}
7965

8066
output "service_endpoints" {
File renamed without changes.
File renamed without changes.

examples/full/tde/gcp/main.tf renamed to examples/tde/gcp/main.tf

+4-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ resource "clickhouse_service" "service" {
7171
backup_retention_period_in_hours = 48
7272
}
7373

74-
has_transparent_data_encryption = true
74+
transparent_data_encryption = {
75+
enabled = true
76+
key_id = "arn:aws:kms:us-east-2:XXXXXXXX:key/12345-6789-abcd-ef01-23456789abcde"
77+
}
7578
}
7679

7780
output "service_endpoints" {
File renamed without changes.

pkg/internal/api/models.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ type Service struct {
6262
PrivateEndpointIds []string `json:"privateEndpointIds,omitempty"`
6363
EncryptionKey string `json:"encryptionKey,omitempty"`
6464
EncryptionAssumedRoleIdentifier string `json:"encryptionAssumedRoleIdentifier,omitempty"`
65-
HasTransparentDataEncryption *bool `json:"hasTransparentDataEncryption,omitempty"`
65+
HasTransparentDataEncryption bool `json:"hasTransparentDataEncryption,omitempty"`
66+
TransparentEncryptionDataKeyID string `json:"transparentDataEncryptionKeyId,omitempty"`
6667
BackupConfiguration *BackupConfiguration `json:"backupConfiguration,omitempty"`
6768
ReleaseChannel string `json:"releaseChannel,omitempty"`
6869
QueryAPIEndpoints *ServiceQueryEndpoint `json:"-"`

pkg/resource/models/service_resource.go

+23-2
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,27 @@ func (e OptionalEndpoint) ObjectValue() basetypes.ObjectValue {
133133
})
134134
}
135135

136+
type TransparentEncryptionData struct {
137+
Enabled types.Bool `tfsdk:"enabled"`
138+
KeyID types.String `tfsdk:"key_id"`
139+
}
140+
141+
func (t TransparentEncryptionData) ObjectType() types.ObjectType {
142+
return types.ObjectType{
143+
AttrTypes: map[string]attr.Type{
144+
"enabled": types.BoolType,
145+
"key_id": types.StringType,
146+
},
147+
}
148+
}
149+
150+
func (t TransparentEncryptionData) ObjectValue() basetypes.ObjectValue {
151+
return types.ObjectValueMust(t.ObjectType().AttrTypes, map[string]attr.Value{
152+
"enabled": t.Enabled,
153+
"key_id": t.KeyID,
154+
})
155+
}
156+
136157
type QueryAPIEndpoints struct {
137158
APIKeyIDs types.List `tfsdk:"api_key_ids"`
138159
Roles types.List `tfsdk:"roles"`
@@ -208,7 +229,7 @@ type ServiceResourceModel struct {
208229
PrivateEndpointConfig types.Object `tfsdk:"private_endpoint_config"`
209230
EncryptionKey types.String `tfsdk:"encryption_key"`
210231
EncryptionAssumedRoleIdentifier types.String `tfsdk:"encryption_assumed_role_identifier"`
211-
HasTransparentDataEncryption types.Bool `tfsdk:"has_transparent_data_encryption"`
232+
TransparentEncryptionData types.Object `tfsdk:"transparent_data_encryption"`
212233
QueryAPIEndpoints types.Object `tfsdk:"query_api_endpoints"`
213234
BackupConfiguration types.Object `tfsdk:"backup_configuration"`
214235
}
@@ -237,7 +258,7 @@ func (m *ServiceResourceModel) Equals(b ServiceResourceModel) bool {
237258
!m.PrivateEndpointConfig.Equal(b.PrivateEndpointConfig) ||
238259
!m.EncryptionKey.Equal(b.EncryptionKey) ||
239260
!m.EncryptionAssumedRoleIdentifier.Equal(b.EncryptionAssumedRoleIdentifier) ||
240-
!m.HasTransparentDataEncryption.Equal(b.HasTransparentDataEncryption) ||
261+
!m.TransparentEncryptionData.Equal(b.TransparentEncryptionData) ||
241262
!m.IpAccessList.Equal(b.IpAccessList) ||
242263
!m.QueryAPIEndpoints.Equal(b.QueryAPIEndpoints) ||
243264
!m.BackupConfiguration.Equal(b.BackupConfiguration) {

pkg/resource/models/service_resource_test.go

+8
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ func TestServiceResource_Equals(t *testing.T) {
244244
}).Get(),
245245
want: false,
246246
},
247+
{
248+
name: "TransparentEncryptionDataKeyID changed",
249+
a: base,
250+
b: test.NewUpdater(base).Update(func(src *ServiceResourceModel) {
251+
src.TransparentEncryptionDataKeyID = types.StringValue("changed")
252+
}).Get(),
253+
want: false,
254+
},
247255
{
248256
name: "BackupConfiguration.BackupPeriodInHours changed",
249257
a: base,

pkg/resource/service.go

+108-12
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator"
1515
"github.com/hashicorp/terraform-plugin-framework-validators/int32validator"
16+
"github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator"
1617
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
1718
"github.com/hashicorp/terraform-plugin-framework/attr"
1819
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
@@ -335,13 +336,21 @@ func (r *ServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, re
335336
Description: "Custom role identifier ARN.",
336337
Optional: true,
337338
},
338-
"has_transparent_data_encryption": schema.BoolAttribute{
339-
Description: "If true, the Transparent Data Encryption (TDE) feature is enabled in the service. Only supported in AWS and GCP. Requires an organization with the Enterprise plan.",
339+
"transparent_data_encryption": schema.SingleNestedAttribute{
340+
Description: "Configuration of the Transparent Data Encryption (TDE) feature. Requires an organization with the Enterprise plan.",
340341
Optional: true,
341-
Computed: true,
342-
PlanModifiers: []planmodifier.Bool{
343-
boolplanmodifier.UseStateForUnknown(),
344-
boolplanmodifier.RequiresReplace(),
342+
Attributes: map[string]schema.Attribute{
343+
"enabled": schema.BoolAttribute{
344+
Required: true,
345+
Description: "If true, TDE is enabled for the service.",
346+
},
347+
"key_id": schema.StringAttribute{
348+
Optional: true,
349+
Description: "ID of the Encryption key to use for data encryption. Must be an ARN for AWS services or a Key Resource Path for GCP services.",
350+
},
351+
},
352+
Validators: []validator.Object{
353+
objectvalidator.ConflictsWith(path.Expressions{path.MatchRoot("warehouse_id")}...),
345354
},
346355
},
347356
"query_api_endpoints": schema.SingleNestedAttribute{
@@ -518,6 +527,49 @@ func (r *ServiceResource) ModifyPlan(ctx context.Context, req resource.ModifyPla
518527
)
519528
}
520529
}
530+
531+
var isEnabled, wantEnabled bool
532+
var isKey, wantKey string
533+
if !state.TransparentEncryptionData.IsNull() {
534+
stateTDE := models.TransparentEncryptionData{}
535+
state.TransparentEncryptionData.As(ctx, &stateTDE, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: false, UnhandledUnknownAsEmpty: false})
536+
isEnabled = stateTDE.Enabled.ValueBool()
537+
isKey = stateTDE.KeyID.ValueString()
538+
}
539+
540+
if !plan.TransparentEncryptionData.IsNull() && !plan.TransparentEncryptionData.IsUnknown() {
541+
planTDE := models.TransparentEncryptionData{}
542+
plan.TransparentEncryptionData.As(ctx, &planTDE, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: false, UnhandledUnknownAsEmpty: false})
543+
wantEnabled = planTDE.Enabled.ValueBool()
544+
wantKey = planTDE.KeyID.ValueString()
545+
} else {
546+
wantEnabled = false
547+
wantKey = ""
548+
}
549+
550+
if isEnabled && !wantEnabled {
551+
resp.Diagnostics.AddAttributeError(
552+
path.Root("transparent_data_encryption.enabled"),
553+
"Invalid Update",
554+
"It is not possible to disable TDE (Transparend data encryption) on an existing service.",
555+
)
556+
}
557+
558+
if !isEnabled && wantEnabled {
559+
resp.Diagnostics.AddAttributeError(
560+
path.Root("transparent_data_encryption.enabled"),
561+
"Invalid Update",
562+
"It is not possible to enable TDE (Transparend data encryption) on an existing service.",
563+
)
564+
}
565+
566+
if isKey != "" && wantKey == "" {
567+
resp.Diagnostics.AddAttributeError(
568+
path.Root("transparent_data_encryption.key_id"),
569+
"Invalid Update",
570+
"It is not possible to remove the key_id for TDE (Transparend data encryption) on a service. If the KeyID was updated outside terraform, please set the same value in the transparent_data_encryption.key_id field.",
571+
)
572+
}
521573
}
522574

523575
if plan.Tier.ValueString() == api.TierDevelopment {
@@ -795,10 +847,14 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest
795847
}
796848
}
797849

798-
if service.Tier == api.TierPPv2 {
799-
if !plan.HasTransparentDataEncryption.IsUnknown() && !plan.HasTransparentDataEncryption.IsNull() {
800-
service.HasTransparentDataEncryption = plan.HasTransparentDataEncryption.ValueBoolPointer()
850+
var tde *models.TransparentEncryptionData
851+
if !plan.TransparentEncryptionData.IsNull() {
852+
tde = &models.TransparentEncryptionData{}
853+
diag := plan.TransparentEncryptionData.As(ctx, tde, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: false, UnhandledUnknownAsEmpty: false})
854+
if diag.HasError() {
855+
return
801856
}
857+
service.HasTransparentDataEncryption = tde.Enabled.ValueBool()
802858
}
803859

804860
service.IdleScaling = plan.IdleScaling.ValueBool()
@@ -973,6 +1029,18 @@ func (r *ServiceResource) Create(ctx context.Context, req resource.CreateRequest
9731029
return
9741030
}
9751031
}
1032+
1033+
// Set TDE key
1034+
if tde != nil && tde.Enabled.ValueBool() && tde.KeyID.ValueString() != "" {
1035+
err = r.client.RotateTDEKey(ctx, s.Id, tde.KeyID.ValueString())
1036+
if err != nil {
1037+
resp.Diagnostics.AddError(
1038+
"Error setting TDE encryption key id",
1039+
"Could not set TDE encryption key id, unexpected error: "+err.Error(),
1040+
)
1041+
return
1042+
}
1043+
}
9761044
}
9771045

9781046
// Map response body to schema and populate Computed attribute values
@@ -1350,6 +1418,26 @@ func (r *ServiceResource) Update(ctx context.Context, req resource.UpdateRequest
13501418
}
13511419
}
13521420

1421+
// TDE Key rotation
1422+
{
1423+
if !plan.TransparentEncryptionData.Equal(state.TransparentEncryptionData) {
1424+
tde := &models.TransparentEncryptionData{}
1425+
diag := plan.TransparentEncryptionData.As(ctx, tde, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: false, UnhandledUnknownAsEmpty: false})
1426+
if diag.HasError() {
1427+
return
1428+
}
1429+
1430+
err := r.client.RotateTDEKey(ctx, serviceId, tde.KeyID.ValueString())
1431+
if err != nil {
1432+
resp.Diagnostics.AddError(
1433+
"Error rotating TDE encryption key",
1434+
"Could not rotate TDE encryption, unexpected error: "+err.Error(),
1435+
)
1436+
return
1437+
}
1438+
}
1439+
}
1440+
13531441
err := r.syncServiceState(ctx, &plan, true)
13541442
if err != nil {
13551443
resp.Diagnostics.AddError(
@@ -1823,10 +1911,18 @@ func (r *ServiceResource) syncServiceState(ctx context.Context, state *models.Se
18231911
state.EncryptionAssumedRoleIdentifier = types.StringNull()
18241912
}
18251913

1826-
if service.HasTransparentDataEncryption != nil {
1827-
state.HasTransparentDataEncryption = types.BoolValue(*service.HasTransparentDataEncryption)
1914+
if service.HasTransparentDataEncryption {
1915+
tde := models.TransparentEncryptionData{
1916+
Enabled: types.BoolValue(service.HasTransparentDataEncryption),
1917+
}
1918+
if service.TransparentEncryptionDataKeyID != "" {
1919+
tde.KeyID = types.StringValue(service.TransparentEncryptionDataKeyID)
1920+
} else {
1921+
tde.KeyID = types.StringNull()
1922+
}
1923+
state.TransparentEncryptionData = tde.ObjectValue()
18281924
} else {
1829-
state.HasTransparentDataEncryption = types.BoolNull()
1925+
state.TransparentEncryptionData = types.ObjectNull(models.TransparentEncryptionData{}.ObjectType().AttrTypes)
18301926
}
18311927

18321928
if service.QueryAPIEndpoints != nil {

0 commit comments

Comments
 (0)