diff --git a/CHANGELOG.md b/CHANGELOG.md
index f95a29eaa..12769c104 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
- Add `slo_id` validation to `elasticstack_kibana_slo` ([#1221](https://github.com/elastic/terraform-provider-elasticstack/pull/1221))
- Add `ignore_missing_component_templates` to `elasticstack_elasticsearch_index_template` ([#1206](https://github.com/elastic/terraform-provider-elasticstack/pull/1206))
- Prevent provider panic when a script exists in state, but not in Elasticsearch ([#1218](https://github.com/elastic/terraform-provider-elasticstack/pull/1218))
+- Add support for managing cross_cluster API keys in `elasticstack_elasticsearch_security_api_key` ([#1252](https://github.com/elastic/terraform-provider-elasticstack/pull/1252))
- Allow version changes without a destroy/create cycle with `elasticstack_fleet_integration` ([#1255](https://github.com/elastic/terraform-provider-elasticstack/pull/1255)). This fixes an issue where it was impossible to upgrade integrations which are used by an integration policy.
- Add `namespace` attribute to `elasticstack_kibana_synthetics_monitor` resource to support setting data stream namespace independently from `space_id` ([#1247](https://github.com/elastic/terraform-provider-elasticstack/pull/1247))
diff --git a/docs/resources/elasticsearch_security_api_key.md b/docs/resources/elasticsearch_security_api_key.md
index b014d4bd7..d7389c0b1 100644
--- a/docs/resources/elasticsearch_security_api_key.md
+++ b/docs/resources/elasticsearch_security_api_key.md
@@ -76,6 +76,38 @@ output "api_key" {
value = elasticstack_elasticsearch_security_api_key.api_key
sensitive = true
}
+
+# Example: Cross-cluster API key
+resource "elasticstack_elasticsearch_security_api_key" "cross_cluster_key" {
+ name = "My Cross-Cluster API Key"
+ type = "cross_cluster"
+
+ # Define access permissions for cross-cluster operations
+ access = {
+
+ # Grant replication access to specific indices
+ replication = [
+ {
+ names = ["archive-*"]
+ }
+ ]
+ }
+
+ # Set the expiration for the API key
+ expiration = "30d"
+
+ # Set arbitrary metadata
+ metadata = jsonencode({
+ description = "Cross-cluster key for production environment"
+ environment = "production"
+ team = "platform"
+ })
+}
+
+output "cross_cluster_api_key" {
+ value = elasticstack_elasticsearch_security_api_key.cross_cluster_key
+ sensitive = true
+}
```
@@ -87,10 +119,12 @@ output "api_key" {
### Optional
+- `access` (Attributes) Access configuration for cross-cluster API keys. Only applicable when type is 'cross_cluster'. (see [below for nested schema](#nestedatt--access))
- `elasticsearch_connection` (Block List, Deprecated) Elasticsearch connection configuration block. (see [below for nested schema](#nestedblock--elasticsearch_connection))
- `expiration` (String) Expiration time for the API key. By default, API keys never expire.
- `metadata` (String) Arbitrary metadata that you want to associate with the API key.
- `role_descriptors` (String) Role descriptors for this API key.
+- `type` (String) The type of API key. Valid values are 'rest' (default) and 'cross_cluster'. Cross-cluster API keys are used for cross-cluster search and replication.
### Read-Only
@@ -100,6 +134,37 @@ output "api_key" {
- `id` (String) Internal identifier of the resource.
- `key_id` (String) Unique id for this API key.
+
+### Nested Schema for `access`
+
+Optional:
+
+- `replication` (Attributes List) A list of replication configurations for which the cross-cluster API key will have replication privileges. (see [below for nested schema](#nestedatt--access--replication))
+- `search` (Attributes List) A list of search configurations for which the cross-cluster API key will have search privileges. (see [below for nested schema](#nestedatt--access--search))
+
+
+### Nested Schema for `access.replication`
+
+Required:
+
+- `names` (List of String) A list of index patterns for replication.
+
+
+
+### Nested Schema for `access.search`
+
+Required:
+
+- `names` (List of String) A list of index patterns for search.
+
+Optional:
+
+- `allow_restricted_indices` (Boolean) Whether to allow access to restricted indices.
+- `field_security` (String) Field-level security configuration in JSON format.
+- `query` (String) Query to filter documents for search operations in JSON format.
+
+
+
### Nested Schema for `elasticsearch_connection`
diff --git a/examples/resources/elasticstack_elasticsearch_security_api_key/resource.tf b/examples/resources/elasticstack_elasticsearch_security_api_key/resource.tf
index f974e130b..53c13099b 100644
--- a/examples/resources/elasticstack_elasticsearch_security_api_key/resource.tf
+++ b/examples/resources/elasticstack_elasticsearch_security_api_key/resource.tf
@@ -61,3 +61,35 @@ output "api_key" {
value = elasticstack_elasticsearch_security_api_key.api_key
sensitive = true
}
+
+# Example: Cross-cluster API key
+resource "elasticstack_elasticsearch_security_api_key" "cross_cluster_key" {
+ name = "My Cross-Cluster API Key"
+ type = "cross_cluster"
+
+ # Define access permissions for cross-cluster operations
+ access = {
+
+ # Grant replication access to specific indices
+ replication = [
+ {
+ names = ["archive-*"]
+ }
+ ]
+ }
+
+ # Set the expiration for the API key
+ expiration = "30d"
+
+ # Set arbitrary metadata
+ metadata = jsonencode({
+ description = "Cross-cluster key for production environment"
+ environment = "production"
+ team = "platform"
+ })
+}
+
+output "cross_cluster_api_key" {
+ value = elasticstack_elasticsearch_security_api_key.cross_cluster_key
+ sensitive = true
+}
diff --git a/internal/clients/elasticsearch/security.go b/internal/clients/elasticsearch/security.go
index 6ce83aa09..bfeb5e3e5 100644
--- a/internal/clients/elasticsearch/security.go
+++ b/internal/clients/elasticsearch/security.go
@@ -440,3 +440,58 @@ func DeleteApiKey(apiClient *clients.ApiClient, id string) fwdiag.Diagnostics {
}
return nil
}
+
+func CreateCrossClusterApiKey(apiClient *clients.ApiClient, apikey *models.CrossClusterApiKey) (*models.CrossClusterApiKeyCreateResponse, fwdiag.Diagnostics) {
+ apikeyBytes, err := json.Marshal(apikey)
+ if err != nil {
+ return nil, utils.FrameworkDiagFromError(err)
+ }
+
+ esClient, err := apiClient.GetESClient()
+ if err != nil {
+ return nil, utils.FrameworkDiagFromError(err)
+ }
+ res, err := esClient.Security.CreateCrossClusterAPIKey(bytes.NewReader(apikeyBytes))
+ if err != nil {
+ return nil, utils.FrameworkDiagFromError(err)
+ }
+ defer res.Body.Close()
+ if diags := utils.CheckError(res, "Unable to create cross cluster apikey"); diags.HasError() {
+ return nil, utils.FrameworkDiagsFromSDK(diags)
+ }
+
+ var apiKey models.CrossClusterApiKeyCreateResponse
+
+ if err := json.NewDecoder(res.Body).Decode(&apiKey); err != nil {
+ return nil, utils.FrameworkDiagFromError(err)
+ }
+
+ return &apiKey, nil
+}
+
+func UpdateCrossClusterApiKey(apiClient *clients.ApiClient, apikey models.CrossClusterApiKey) fwdiag.Diagnostics {
+ id := apikey.ID
+
+ apikey.Expiration = ""
+ apikey.Name = ""
+ apikey.ID = ""
+ apikeyBytes, err := json.Marshal(apikey)
+ if err != nil {
+ return utils.FrameworkDiagFromError(err)
+ }
+
+ esClient, err := apiClient.GetESClient()
+ if err != nil {
+ return utils.FrameworkDiagFromError(err)
+ }
+ res, err := esClient.Security.UpdateCrossClusterAPIKey(id, bytes.NewReader(apikeyBytes))
+ if err != nil {
+ return utils.FrameworkDiagFromError(err)
+ }
+ defer res.Body.Close()
+ if diags := utils.CheckError(res, "Unable to update cross cluster apikey"); diags.HasError() {
+ return utils.FrameworkDiagsFromSDK(diags)
+ }
+
+ return nil
+}
diff --git a/internal/elasticsearch/security/api_key/acc_test.go b/internal/elasticsearch/security/api_key/acc_test.go
index be998ea3f..ec6ff99fc 100644
--- a/internal/elasticsearch/security/api_key/acc_test.go
+++ b/internal/elasticsearch/security/api_key/acc_test.go
@@ -450,3 +450,104 @@ func checkResourceSecurityApiKeyDestroy(s *terraform.State) error {
}
return nil
}
+
+func TestAccResourceSecurityApiKeyCrossCluster(t *testing.T) {
+ // generate a random name
+ apiKeyName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { acctest.PreCheck(t) },
+ CheckDestroy: checkResourceSecurityApiKeyDestroy,
+ ProtoV6ProviderFactories: acctest.Providers,
+ Steps: []resource.TestStep{
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(api_key.MinVersionWithCrossCluster),
+ Config: testAccResourceSecurityApiKeyCrossClusterCreate(apiKeyName),
+ Check: resource.ComposeTestCheckFunc(
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "name", apiKeyName),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "type", "cross_cluster"),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "access.search.0.names.0", "logs-*"),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "access.search.0.names.1", "metrics-*"),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "access.replication.0.names.0", "archive-*"),
+ ),
+ },
+ {
+ SkipFunc: versionutils.CheckIfVersionIsUnsupported(api_key.MinVersionWithCrossCluster),
+ Config: testAccResourceSecurityApiKeyCrossClusterUpdate(apiKeyName),
+ Check: resource.ComposeTestCheckFunc(
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "name", apiKeyName),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "type", "cross_cluster"),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "access.search.0.names.0", "log-*"),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "access.search.0.names.1", "metrics-*"),
+ resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "access.replication.0.names.0", "archives-*"),
+ ),
+ },
+ },
+ })
+}
+
+func testAccResourceSecurityApiKeyCrossClusterCreate(apiKeyName string) string {
+ return fmt.Sprintf(`
+provider "elasticstack" {
+ elasticsearch {}
+}
+
+resource "elasticstack_elasticsearch_security_api_key" "test" {
+ name = "%s"
+ type = "cross_cluster"
+
+ access = {
+ search = [
+ {
+ names = ["logs-*", "metrics-*"]
+ }
+ ]
+ replication = [
+ {
+ names = ["archive-*"]
+ }
+ ]
+ }
+
+ expiration = "30d"
+
+ metadata = jsonencode({
+ description = "Cross-cluster test key"
+ environment = "test"
+ })
+}
+ `, apiKeyName)
+}
+
+func testAccResourceSecurityApiKeyCrossClusterUpdate(apiKeyName string) string {
+ return fmt.Sprintf(`
+provider "elasticstack" {
+ elasticsearch {}
+}
+
+resource "elasticstack_elasticsearch_security_api_key" "test" {
+ name = "%s"
+ type = "cross_cluster"
+
+ access = {
+ search = [
+ {
+ names = ["log-*", "metrics-*"]
+ }
+ ]
+ replication = [
+ {
+ names = ["archives-*"]
+ }
+ ]
+ }
+
+ expiration = "30d"
+
+ metadata = jsonencode({
+ description = "Cross-cluster test key"
+ environment = "test"
+ })
+}
+ `, apiKeyName)
+}
diff --git a/internal/elasticsearch/security/api_key/create.go b/internal/elasticsearch/security/api_key/create.go
index da0b95580..18f762133 100644
--- a/internal/elasticsearch/security/api_key/create.go
+++ b/internal/elasticsearch/security/api_key/create.go
@@ -28,26 +28,18 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp *
return
}
- apiModel, diags := r.buildApiModel(ctx, planModel, client)
- resp.Diagnostics.Append(diags...)
- if resp.Diagnostics.HasError() {
- return
- }
-
- putResponse, diags := elasticsearch.CreateApiKey(client, &apiModel)
- resp.Diagnostics.Append(diags...)
- if putResponse == nil || resp.Diagnostics.HasError() {
- return
+ if planModel.Type.ValueString() == "cross_cluster" {
+ createDiags := r.createCrossClusterApiKey(ctx, client, &planModel)
+ resp.Diagnostics.Append(createDiags...)
+ } else {
+ createDiags := r.createApiKey(ctx, client, &planModel)
+ resp.Diagnostics.Append(createDiags...)
}
- id, sdkDiags := client.ID(ctx, putResponse.Id)
- resp.Diagnostics.Append(utils.FrameworkDiagsFromSDK(sdkDiags)...)
if resp.Diagnostics.HasError() {
return
}
- planModel.ID = basetypes.NewStringValue(id.String())
- planModel.populateFromCreate(*putResponse)
resp.Diagnostics.Append(resp.State.Set(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
@@ -105,3 +97,81 @@ func doesCurrentVersionSupportRestrictionOnApiKey(ctx context.Context, client *c
return currentVersion.GreaterThanOrEqual(MinVersionWithRestriction), nil
}
+
+func doesCurrentVersionSupportCrossClusterApiKey(ctx context.Context, client *clients.ApiClient) (bool, diag.Diagnostics) {
+ currentVersion, diags := client.ServerVersion(ctx)
+
+ if diags.HasError() {
+ return false, utils.FrameworkDiagsFromSDK(diags)
+ }
+
+ return currentVersion.GreaterThanOrEqual(MinVersionWithCrossCluster), nil
+}
+
+func (r *Resource) createCrossClusterApiKey(ctx context.Context, client *clients.ApiClient, planModel *tfModel) diag.Diagnostics {
+ // Check if the current version supports cross-cluster API keys
+ isSupported, diags := doesCurrentVersionSupportCrossClusterApiKey(ctx, client)
+ if diags.HasError() {
+ return diags
+ }
+ if !isSupported {
+ return diag.Diagnostics{
+ diag.NewErrorDiagnostic(
+ "Cross-cluster API keys not supported",
+ fmt.Sprintf("Cross-cluster API keys are only supported in Elasticsearch version %s and above.", MinVersionWithCrossCluster.String()),
+ ),
+ }
+ }
+
+ // Handle cross-cluster API key creation
+ crossClusterModel, diags := planModel.toCrossClusterAPIModel(ctx)
+ if diags.HasError() {
+ return diags
+ }
+
+ putResponse, createDiags := elasticsearch.CreateCrossClusterApiKey(client, &crossClusterModel)
+ if createDiags.HasError() {
+ return diag.Diagnostics(createDiags)
+ }
+ if putResponse == nil {
+ return diag.Diagnostics{
+ diag.NewErrorDiagnostic("API Key Creation Failed", "Cross-cluster API key creation returned nil response"),
+ }
+ }
+
+ id, sdkDiags := client.ID(ctx, putResponse.Id)
+ if sdkDiags.HasError() {
+ return utils.FrameworkDiagsFromSDK(sdkDiags)
+ }
+
+ planModel.ID = basetypes.NewStringValue(id.String())
+ planModel.populateFromCrossClusterCreate(*putResponse)
+ return nil
+}
+
+func (r *Resource) createApiKey(ctx context.Context, client *clients.ApiClient, planModel *tfModel) diag.Diagnostics {
+ // Handle regular API key creation
+ apiModel, diags := r.buildApiModel(ctx, *planModel, client)
+ if diags.HasError() {
+ return diags
+ }
+
+ putResponse, createDiags := elasticsearch.CreateApiKey(client, &apiModel)
+ if createDiags.HasError() {
+ return diag.Diagnostics(createDiags)
+ }
+ if putResponse == nil {
+ return diag.Diagnostics{
+ diag.NewErrorDiagnostic("API Key Creation Failed", "API key creation returned nil response"),
+ }
+ }
+
+ id, sdkDiags := client.ID(ctx, putResponse.Id)
+ if sdkDiags.HasError() {
+ return utils.FrameworkDiagsFromSDK(sdkDiags)
+ }
+
+ planModel.ID = basetypes.NewStringValue(id.String())
+ planModel.populateFromCreate(*putResponse)
+ return nil
+}
diff --git a/internal/elasticsearch/security/api_key/models.go b/internal/elasticsearch/security/api_key/models.go
index 7a9af1ec7..8847f68e9 100644
--- a/internal/elasticsearch/security/api_key/models.go
+++ b/internal/elasticsearch/security/api_key/models.go
@@ -1,6 +1,7 @@
package api_key
import (
+ "context"
"encoding/json"
"github.com/elastic/terraform-provider-elasticstack/internal/clients"
@@ -13,15 +14,33 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)
+type searchModel struct {
+ Names types.List `tfsdk:"names"`
+ FieldSecurity jsontypes.Normalized `tfsdk:"field_security"`
+ Query jsontypes.Normalized `tfsdk:"query"`
+ AllowRestrictedIndices types.Bool `tfsdk:"allow_restricted_indices"`
+}
+
+type replicationModel struct {
+ Names types.List `tfsdk:"names"`
+}
+
+type accessModel struct {
+ Search types.List `tfsdk:"search"`
+ Replication types.List `tfsdk:"replication"`
+}
+
type tfModel struct {
ID types.String `tfsdk:"id"`
ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"`
KeyID types.String `tfsdk:"key_id"`
Name types.String `tfsdk:"name"`
+ Type types.String `tfsdk:"type"`
RoleDescriptors jsontypes.Normalized `tfsdk:"role_descriptors"`
Expiration types.String `tfsdk:"expiration"`
ExpirationTimestamp types.Int64 `tfsdk:"expiration_timestamp"`
Metadata jsontypes.Normalized `tfsdk:"metadata"`
+ Access types.Object `tfsdk:"access"`
APIKey types.String `tfsdk:"api_key"`
Encoded types.String `tfsdk:"encoded"`
}
@@ -49,10 +68,113 @@ func (model tfModel) toAPIModel() (models.ApiKey, diag.Diagnostics) {
}
}
- if utils.IsKnown(model.RoleDescriptors) {
- diags := model.RoleDescriptors.Unmarshal(&apiModel.RolesDescriptors)
+ diags := model.RoleDescriptors.Unmarshal(&apiModel.RolesDescriptors)
+ if diags.HasError() {
+ return models.ApiKey{}, diags
+ }
+
+ return apiModel, nil
+}
+
+func (model tfModel) toCrossClusterAPIModel(ctx context.Context) (models.CrossClusterApiKey, diag.Diagnostics) {
+ apiModel := models.CrossClusterApiKey{
+ ID: model.KeyID.ValueString(),
+ Name: model.Name.ValueString(),
+ Expiration: model.Expiration.ValueString(),
+ }
+
+ if utils.IsKnown(model.Metadata) {
+ diags := model.Metadata.Unmarshal(&apiModel.Metadata)
if diags.HasError() {
- return models.ApiKey{}, diags
+ return models.CrossClusterApiKey{}, diags
+ }
+ }
+
+ // Build the access configuration
+ access := &models.CrossClusterApiKeyAccess{}
+
+ if utils.IsKnown(model.Access) {
+ var accessData accessModel
+ diags := model.Access.As(ctx, &accessData, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return models.CrossClusterApiKey{}, diags
+ }
+
+ if utils.IsKnown(accessData.Search) {
+ var searchObjects []searchModel
+ diags := accessData.Search.ElementsAs(ctx, &searchObjects, false)
+ if diags.HasError() {
+ return models.CrossClusterApiKey{}, diags
+ }
+
+ var searchEntries []models.CrossClusterApiKeyAccessEntry
+ for _, searchObj := range searchObjects {
+ entry := models.CrossClusterApiKeyAccessEntry{}
+
+ if utils.IsKnown(searchObj.Names) {
+ var names []string
+ diags := searchObj.Names.ElementsAs(ctx, &names, false)
+ if diags.HasError() {
+ return models.CrossClusterApiKey{}, diags
+ }
+ entry.Names = names
+ }
+
+ if utils.IsKnown(searchObj.FieldSecurity) && !searchObj.FieldSecurity.IsNull() {
+ var fieldSecurity models.FieldSecurity
+ diags := json.Unmarshal([]byte(searchObj.FieldSecurity.ValueString()), &fieldSecurity)
+ if diags != nil {
+ return models.CrossClusterApiKey{}, diag.Diagnostics{diag.NewErrorDiagnostic("Failed to unmarshal field_security", diags.Error())}
+ }
+ entry.FieldSecurity = &fieldSecurity
+ }
+
+ if utils.IsKnown(searchObj.Query) && !searchObj.Query.IsNull() {
+ query := searchObj.Query.ValueString()
+ entry.Query = &query
+ }
+
+ if utils.IsKnown(searchObj.AllowRestrictedIndices) {
+ allowRestricted := searchObj.AllowRestrictedIndices.ValueBool()
+ entry.AllowRestrictedIndices = &allowRestricted
+ }
+
+ searchEntries = append(searchEntries, entry)
+ }
+ if len(searchEntries) > 0 {
+ access.Search = searchEntries
+ }
+ }
+
+ if utils.IsKnown(accessData.Replication) {
+ var replicationObjects []replicationModel
+ diags := accessData.Replication.ElementsAs(ctx, &replicationObjects, false)
+ if diags.HasError() {
+ return models.CrossClusterApiKey{}, diags
+ }
+
+ var replicationEntries []models.CrossClusterApiKeyAccessEntry
+ for _, replicationObj := range replicationObjects {
+ if utils.IsKnown(replicationObj.Names) {
+ var names []string
+ diags := replicationObj.Names.ElementsAs(ctx, &names, false)
+ if diags.HasError() {
+ return models.CrossClusterApiKey{}, diags
+ }
+ if len(names) > 0 {
+ replicationEntries = append(replicationEntries, models.CrossClusterApiKeyAccessEntry{
+ Names: names,
+ })
+ }
+ }
+ }
+ if len(replicationEntries) > 0 {
+ access.Replication = replicationEntries
+ }
+ }
+
+ if access.Search != nil || access.Replication != nil {
+ apiModel.Access = access
}
}
@@ -66,6 +188,16 @@ func (model *tfModel) populateFromCreate(apiKey models.ApiKeyCreateResponse) {
model.Encoded = basetypes.NewStringValue(apiKey.EncodedKey)
}
+func (model *tfModel) populateFromCrossClusterCreate(apiKey models.CrossClusterApiKeyCreateResponse) {
+ model.KeyID = basetypes.NewStringValue(apiKey.Id)
+ model.Name = basetypes.NewStringValue(apiKey.Name)
+ model.APIKey = basetypes.NewStringValue(apiKey.Key)
+ model.Encoded = basetypes.NewStringValue(apiKey.EncodedKey)
+ if apiKey.Expiration > 0 {
+ model.ExpirationTimestamp = basetypes.NewInt64Value(apiKey.Expiration)
+ }
+}
+
func (model *tfModel) populateFromAPI(apiKey models.ApiKeyResponse, serverVersion *version.Version) diag.Diagnostics {
model.KeyID = basetypes.NewStringValue(apiKey.Id)
model.Name = basetypes.NewStringValue(apiKey.Name)
diff --git a/internal/elasticsearch/security/api_key/resource.go b/internal/elasticsearch/security/api_key/resource.go
index 5cc9027ef..3a3e0c27d 100644
--- a/internal/elasticsearch/security/api_key/resource.go
+++ b/internal/elasticsearch/security/api_key/resource.go
@@ -15,11 +15,13 @@ import (
var _ resource.Resource = &Resource{}
var _ resource.ResourceWithConfigure = &Resource{}
var _ resource.ResourceWithUpgradeState = &Resource{}
+
var (
MinVersion = version.Must(version.NewVersion("8.0.0")) // Enabled in 8.0
MinVersionWithUpdate = version.Must(version.NewVersion("8.4.0"))
MinVersionReturningRoleDescriptors = version.Must(version.NewVersion("8.5.0"))
- MinVersionWithRestriction = version.Must(version.NewVersion("8.9.0")) // Enabled in 8.0
+ MinVersionWithRestriction = version.Must(version.NewVersion("8.9.0")) // Enabled in 8.0
+ MinVersionWithCrossCluster = version.Must(version.NewVersion("8.10.0")) // Cross-cluster API keys enabled in 8.10
)
type Resource struct {
diff --git a/internal/elasticsearch/security/api_key/schema.go b/internal/elasticsearch/security/api_key/schema.go
index 74f688785..aeb62f0d3 100644
--- a/internal/elasticsearch/security/api_key/schema.go
+++ b/internal/elasticsearch/security/api_key/schema.go
@@ -12,11 +12,18 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema"
+ "github.com/elastic/terraform-provider-elasticstack/internal/utils/planmodifiers"
)
-const currentSchemaVersion int64 = 1
+const (
+ currentSchemaVersion int64 = 2
+ restAPIKeyType = "rest"
+ crossClusterAPIKeyType = "cross_cluster"
+ defaultAPIKeyType = restAPIKeyType
+)
func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = r.getSchema(currentSchemaVersion)
@@ -25,7 +32,7 @@ func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *res
func (r *Resource) getSchema(version int64) schema.Schema {
return schema.Schema{
Version: version,
- Description: "Creates an API key for access without requiring basic authentication. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html",
+ Description: "Creates an API key for access without requiring basic authentication. Supports both regular API keys and cross-cluster API keys. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html and https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-cross-cluster-api-key.html",
Blocks: map[string]schema.Block{
"elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false),
},
@@ -55,14 +62,31 @@ func (r *Resource) getSchema(version int64) schema.Schema {
stringplanmodifier.RequiresReplace(),
},
},
+ "type": schema.StringAttribute{
+ Description: "The type of API key. Valid values are 'rest' (default) and 'cross_cluster'. Cross-cluster API keys are used for cross-cluster search and replication.",
+ Optional: true,
+ Computed: true,
+ Validators: []validator.String{
+ stringvalidator.OneOf(defaultAPIKeyType, crossClusterAPIKeyType),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ stringplanmodifier.UseStateForUnknown(),
+ planmodifiers.StringUseDefaultIfUnknown(defaultAPIKeyType),
+ },
+ },
"role_descriptors": schema.StringAttribute{
Description: "Role descriptors for this API key.",
CustomType: jsontypes.NormalizedType{},
Optional: true,
Computed: true,
+ Validators: []validator.String{
+ RequiresType(defaultAPIKeyType),
+ },
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
r.requiresReplaceIfUpdateNotSupported(),
+ SetUnknownIfAccessHasChanges(),
},
},
"expiration": schema.StringAttribute{
@@ -89,6 +113,55 @@ func (r *Resource) getSchema(version int64) schema.Schema {
r.requiresReplaceIfUpdateNotSupported(),
},
},
+ "access": schema.SingleNestedAttribute{
+ Description: "Access configuration for cross-cluster API keys. Only applicable when type is 'cross_cluster'.",
+ Optional: true,
+ Validators: []validator.Object{
+ RequiresType(crossClusterAPIKeyType),
+ },
+ Attributes: map[string]schema.Attribute{
+ "search": schema.ListNestedAttribute{
+ Description: "A list of search configurations for which the cross-cluster API key will have search privileges.",
+ Optional: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "names": schema.ListAttribute{
+ Description: "A list of index patterns for search.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ "field_security": schema.StringAttribute{
+ Description: "Field-level security configuration in JSON format.",
+ Optional: true,
+ CustomType: jsontypes.NormalizedType{},
+ },
+ "query": schema.StringAttribute{
+ Description: "Query to filter documents for search operations in JSON format.",
+ Optional: true,
+ CustomType: jsontypes.NormalizedType{},
+ },
+ "allow_restricted_indices": schema.BoolAttribute{
+ Description: "Whether to allow access to restricted indices.",
+ Optional: true,
+ },
+ },
+ },
+ },
+ "replication": schema.ListNestedAttribute{
+ Description: "A list of replication configurations for which the cross-cluster API key will have replication privileges.",
+ Optional: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "names": schema.ListAttribute{
+ Description: "A list of index patterns for replication.",
+ Required: true,
+ ElementType: types.StringType,
+ },
+ },
+ },
+ },
+ },
+ },
"api_key": schema.StringAttribute{
Description: "Generated API Key.",
Sensitive: true,
diff --git a/internal/elasticsearch/security/api_key/set_unknown_if_access_has_changes.go b/internal/elasticsearch/security/api_key/set_unknown_if_access_has_changes.go
new file mode 100644
index 000000000..f17e95f91
--- /dev/null
+++ b/internal/elasticsearch/security/api_key/set_unknown_if_access_has_changes.go
@@ -0,0 +1,57 @@
+package api_key
+
+import (
+ "context"
+
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+// SetUnknownIfAccessHasChanges returns a plan modifier that sets the current attribute to unknown
+// if the access attribute has changed between state and config for cross-cluster API keys.
+func SetUnknownIfAccessHasChanges() planmodifier.String {
+ return setUnknownIfAccessHasChanges{}
+}
+
+type setUnknownIfAccessHasChanges struct{}
+
+func (s setUnknownIfAccessHasChanges) Description(ctx context.Context) string {
+ return "Sets the attribute value to unknown if the access attribute has changed for cross-cluster API keys"
+}
+
+func (s setUnknownIfAccessHasChanges) MarkdownDescription(ctx context.Context) string {
+ return s.Description(ctx)
+}
+
+func (s setUnknownIfAccessHasChanges) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
+ // Only apply this modifier if we have both state and config
+ if req.State.Raw.IsNull() || req.Config.Raw.IsNull() {
+ return
+ }
+
+ // Get the type attribute to check if this is a cross-cluster API key
+ var keyType types.String
+ resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("type"), &keyType)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Only apply to cross-cluster API keys
+ if keyType.ValueString() != crossClusterAPIKeyType {
+ return
+ }
+
+ // Get the access attribute from state and config to check if it has changed
+ var stateAccess, configAccess types.Object
+ resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("access"), &stateAccess)...)
+ resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("access"), &configAccess)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // If the access attribute has changed between state and config, set the current attribute to Unknown
+ if !stateAccess.Equal(configAccess) {
+ resp.PlanValue = types.StringUnknown()
+ }
+}
diff --git a/internal/elasticsearch/security/api_key/set_unknown_if_access_has_changes_test.go b/internal/elasticsearch/security/api_key/set_unknown_if_access_has_changes_test.go
new file mode 100644
index 000000000..6a2b7712e
--- /dev/null
+++ b/internal/elasticsearch/security/api_key/set_unknown_if_access_has_changes_test.go
@@ -0,0 +1,294 @@
+package api_key
+
+import (
+ "context"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/tfsdk"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-go/tftypes"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSetUnknownIfAccessHasChanges(t *testing.T) {
+ t.Parallel()
+
+ // Define the schema for testing
+ testSchema := schema.Schema{
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{},
+ "role_descriptors": schema.StringAttribute{},
+ "access": schema.SingleNestedAttribute{
+ Attributes: map[string]schema.Attribute{
+ "search": schema.ListNestedAttribute{
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "names": schema.ListAttribute{ElementType: types.StringType},
+ },
+ },
+ },
+ "replication": schema.ListNestedAttribute{
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "names": schema.ListAttribute{ElementType: types.StringType},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ // Define object type for tftypes
+ objectType := tftypes.Object{
+ AttributeTypes: map[string]tftypes.Type{
+ "type": tftypes.String,
+ "role_descriptors": tftypes.String,
+ "access": tftypes.Object{
+ AttributeTypes: map[string]tftypes.Type{
+ "search": tftypes.List{
+ ElementType: tftypes.Object{
+ AttributeTypes: map[string]tftypes.Type{
+ "names": tftypes.List{ElementType: tftypes.String},
+ },
+ },
+ },
+ "replication": tftypes.List{
+ ElementType: tftypes.Object{
+ AttributeTypes: map[string]tftypes.Type{
+ "names": tftypes.List{ElementType: tftypes.String},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ ctx := context.Background()
+ modifier := SetUnknownIfAccessHasChanges()
+
+ t.Run("rest API key should not be affected", func(t *testing.T) {
+ // Create state and config values for rest API key
+ stateValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "rest"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": tftypes.NewValue(objectType.AttributeTypes["access"], nil),
+ }
+
+ configValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "rest"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": tftypes.NewValue(objectType.AttributeTypes["access"], map[string]tftypes.Value{
+ "search": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, nil),
+ "replication": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, nil),
+ }),
+ }
+
+ stateRaw := tftypes.NewValue(objectType, stateValues)
+ configRaw := tftypes.NewValue(objectType, configValues)
+
+ state := tfsdk.State{Raw: stateRaw, Schema: testSchema}
+ config := tfsdk.Config{Raw: configRaw, Schema: testSchema}
+
+ req := planmodifier.StringRequest{
+ Path: path.Root("role_descriptors"),
+ PlanValue: types.StringValue(`{"test": "value"}`),
+ ConfigValue: types.StringValue(`{"test": "value"}`),
+ StateValue: types.StringValue(`{"test": "value"}`),
+ Config: config,
+ State: state,
+ }
+
+ resp := &planmodifier.StringResponse{}
+
+ // Call the plan modifier
+ modifier.PlanModifyString(ctx, req, resp)
+
+ // Check for errors
+ require.False(t, resp.Diagnostics.HasError(), "Plan modifier should not have errors: %v", resp.Diagnostics)
+
+ // For rest type, role_descriptors should not be set to unknown
+ assert.False(t, resp.PlanValue.IsUnknown(), "Plan value should not be unknown for rest API key")
+ })
+
+ t.Run("cross_cluster with unchanged access should not set unknown", func(t *testing.T) {
+ // Create identical access for state and config (no change)
+ accessValue := tftypes.NewValue(objectType.AttributeTypes["access"], nil)
+
+ stateValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "cross_cluster"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": accessValue,
+ }
+
+ configValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "cross_cluster"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": accessValue, // Same as state
+ }
+
+ stateRaw := tftypes.NewValue(objectType, stateValues)
+ configRaw := tftypes.NewValue(objectType, configValues)
+
+ state := tfsdk.State{Raw: stateRaw, Schema: testSchema}
+ config := tfsdk.Config{Raw: configRaw, Schema: testSchema}
+
+ req := planmodifier.StringRequest{
+ Path: path.Root("role_descriptors"),
+ PlanValue: types.StringValue(`{"test": "value"}`),
+ ConfigValue: types.StringValue(`{"test": "value"}`),
+ StateValue: types.StringValue(`{"test": "value"}`),
+ Config: config,
+ State: state,
+ }
+
+ resp := &planmodifier.StringResponse{}
+
+ // Call the plan modifier
+ modifier.PlanModifyString(ctx, req, resp)
+
+ // Check for errors
+ require.False(t, resp.Diagnostics.HasError(), "Plan modifier should not have errors: %v", resp.Diagnostics)
+
+ // For unchanged access, role_descriptors should not be set to unknown
+ assert.False(t, resp.PlanValue.IsUnknown(), "Plan value should not be unknown when access doesn't change")
+ })
+
+ t.Run("cross_cluster with changed access should set unknown", func(t *testing.T) {
+ // State has null access
+ stateAccessValue := tftypes.NewValue(objectType.AttributeTypes["access"], nil)
+
+ // Config has non-null access with search configuration
+ configAccessValue := tftypes.NewValue(objectType.AttributeTypes["access"], map[string]tftypes.Value{
+ "search": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, []tftypes.Value{
+ tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}, map[string]tftypes.Value{
+ "names": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{
+ tftypes.NewValue(tftypes.String, "index-*"),
+ }),
+ }),
+ }),
+ "replication": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, nil),
+ })
+
+ stateValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "cross_cluster"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": stateAccessValue,
+ }
+
+ configValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "cross_cluster"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": configAccessValue, // Different from state
+ }
+
+ stateRaw := tftypes.NewValue(objectType, stateValues)
+ configRaw := tftypes.NewValue(objectType, configValues)
+
+ state := tfsdk.State{Raw: stateRaw, Schema: testSchema}
+ config := tfsdk.Config{Raw: configRaw, Schema: testSchema}
+
+ req := planmodifier.StringRequest{
+ Path: path.Root("role_descriptors"),
+ PlanValue: types.StringValue(`{"test": "value"}`),
+ ConfigValue: types.StringValue(`{"test": "value"}`),
+ StateValue: types.StringValue(`{"test": "value"}`),
+ Config: config,
+ State: state,
+ }
+
+ resp := &planmodifier.StringResponse{}
+
+ // Call the plan modifier
+ modifier.PlanModifyString(ctx, req, resp)
+
+ // Check for errors
+ require.False(t, resp.Diagnostics.HasError(), "Plan modifier should not have errors: %v", resp.Diagnostics)
+
+ // For changed access, role_descriptors should be set to unknown
+ assert.True(t, resp.PlanValue.IsUnknown(), "Plan value should be unknown when access changes for cross_cluster type")
+ })
+
+ t.Run("cross_cluster with different access configurations should set unknown", func(t *testing.T) {
+ // State has search configuration
+ stateAccessValue := tftypes.NewValue(objectType.AttributeTypes["access"], map[string]tftypes.Value{
+ "search": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, []tftypes.Value{
+ tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}, map[string]tftypes.Value{
+ "names": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{
+ tftypes.NewValue(tftypes.String, "old-index-*"),
+ }),
+ }),
+ }),
+ "replication": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, nil),
+ })
+
+ // Config has different search configuration
+ configAccessValue := tftypes.NewValue(objectType.AttributeTypes["access"], map[string]tftypes.Value{
+ "search": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, []tftypes.Value{
+ tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}, map[string]tftypes.Value{
+ "names": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{
+ tftypes.NewValue(tftypes.String, "new-index-*"),
+ }),
+ }),
+ }),
+ "replication": tftypes.NewValue(tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"names": tftypes.List{ElementType: tftypes.String}}}}, nil),
+ })
+
+ stateValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "cross_cluster"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": stateAccessValue,
+ }
+
+ configValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, "cross_cluster"),
+ "role_descriptors": tftypes.NewValue(tftypes.String, `{"test": "value"}`),
+ "access": configAccessValue, // Different from state
+ }
+
+ stateRaw := tftypes.NewValue(objectType, stateValues)
+ configRaw := tftypes.NewValue(objectType, configValues)
+
+ state := tfsdk.State{Raw: stateRaw, Schema: testSchema}
+ config := tfsdk.Config{Raw: configRaw, Schema: testSchema}
+
+ req := planmodifier.StringRequest{
+ Path: path.Root("role_descriptors"),
+ PlanValue: types.StringValue(`{"test": "value"}`),
+ ConfigValue: types.StringValue(`{"test": "value"}`),
+ StateValue: types.StringValue(`{"test": "value"}`),
+ Config: config,
+ State: state,
+ }
+
+ resp := &planmodifier.StringResponse{}
+
+ // Call the plan modifier
+ modifier.PlanModifyString(ctx, req, resp)
+
+ // Check for errors
+ require.False(t, resp.Diagnostics.HasError(), "Plan modifier should not have errors: %v", resp.Diagnostics)
+
+ // For changed access configuration, role_descriptors should be set to unknown
+ assert.True(t, resp.PlanValue.IsUnknown(), "Plan value should be unknown when access configuration changes")
+ })
+
+ t.Run("basic functionality tests", func(t *testing.T) {
+ // Test that the modifier can be created without errors
+ modifier := SetUnknownIfAccessHasChanges()
+ assert.NotNil(t, modifier, "Plan modifier should be created successfully")
+
+ // Test the description method
+ desc := modifier.Description(ctx)
+ assert.NotEmpty(t, desc, "Description should not be empty")
+
+ // Test the markdown description method
+ markdownDesc := modifier.MarkdownDescription(ctx)
+ assert.NotEmpty(t, markdownDesc, "Markdown description should not be empty")
+ })
+}
diff --git a/internal/elasticsearch/security/api_key/state_upgrade.go b/internal/elasticsearch/security/api_key/state_upgrade.go
index b9072fdde..9e31ec7a7 100644
--- a/internal/elasticsearch/security/api_key/state_upgrade.go
+++ b/internal/elasticsearch/security/api_key/state_upgrade.go
@@ -23,6 +23,20 @@ func (r *Resource) UpgradeState(context.Context) map[int64]resource.StateUpgrade
model.Expiration = basetypes.NewStringNull()
}
+ resp.State.Set(ctx, model)
+ },
+ },
+ 1: {
+ PriorSchema: utils.Pointer(r.getSchema(1)),
+ StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
+ var model tfModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ model.Type = basetypes.NewStringValue(defaultAPIKeyType)
+
resp.State.Set(ctx, model)
},
},
diff --git a/internal/elasticsearch/security/api_key/update.go b/internal/elasticsearch/security/api_key/update.go
index 6cd1d3cb1..ea0dd3c84 100644
--- a/internal/elasticsearch/security/api_key/update.go
+++ b/internal/elasticsearch/security/api_key/update.go
@@ -5,6 +5,7 @@ import (
"github.com/elastic/terraform-provider-elasticstack/internal/clients"
"github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
)
@@ -21,13 +22,14 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
return
}
- apiModel, diags := r.buildApiModel(ctx, planModel, client)
- resp.Diagnostics.Append(diags...)
- if resp.Diagnostics.HasError() {
- return
+ if planModel.Type.ValueString() == "cross_cluster" {
+ updateDiags := r.updateCrossClusterApiKey(ctx, client, planModel)
+ resp.Diagnostics.Append(updateDiags...)
+ } else {
+ updateDiags := r.updateApiKey(ctx, client, planModel)
+ resp.Diagnostics.Append(updateDiags...)
}
- resp.Diagnostics.Append(elasticsearch.UpdateApiKey(client, apiModel)...)
if resp.Diagnostics.HasError() {
return
}
@@ -40,3 +42,25 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
resp.Diagnostics.Append(resp.State.Set(ctx, *finalModel)...)
}
+
+func (r *Resource) updateCrossClusterApiKey(ctx context.Context, client *clients.ApiClient, planModel tfModel) diag.Diagnostics {
+ // Handle cross-cluster API key update
+ crossClusterModel, modelDiags := planModel.toCrossClusterAPIModel(ctx)
+ if modelDiags.HasError() {
+ return modelDiags
+ }
+
+ updateDiags := elasticsearch.UpdateCrossClusterApiKey(client, crossClusterModel)
+ return diag.Diagnostics(updateDiags)
+}
+
+func (r *Resource) updateApiKey(ctx context.Context, client *clients.ApiClient, planModel tfModel) diag.Diagnostics {
+ // Handle regular API key update
+ apiModel, modelDiags := r.buildApiModel(ctx, planModel, client)
+ if modelDiags.HasError() {
+ return modelDiags
+ }
+
+ updateDiags := elasticsearch.UpdateApiKey(client, apiModel)
+ return diag.Diagnostics(updateDiags)
+}
diff --git a/internal/elasticsearch/security/api_key/validators.go b/internal/elasticsearch/security/api_key/validators.go
new file mode 100644
index 000000000..d92792d14
--- /dev/null
+++ b/internal/elasticsearch/security/api_key/validators.go
@@ -0,0 +1,85 @@
+package api_key
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/tfsdk"
+)
+
+var (
+ _ validator.String = requiresTypeValidator{}
+ _ validator.Object = requiresTypeValidator{}
+)
+
+// requiresTypeValidator validates that a string attribute is only provided
+// when the resource has a specific value for the "type" attribute.
+type requiresTypeValidator struct {
+ expectedType string
+}
+
+// RequiresType returns a validator which ensures that the configured attribute
+// is only provided when the "type" attribute matches the expected value.
+func RequiresType(expectedType string) requiresTypeValidator {
+ return requiresTypeValidator{
+ expectedType: expectedType,
+ }
+}
+
+func (validator requiresTypeValidator) Description(_ context.Context) string {
+ return fmt.Sprintf("Ensures that the attribute is only provided when type=%s", validator.expectedType)
+}
+
+func (validator requiresTypeValidator) MarkdownDescription(ctx context.Context) string {
+ return validator.Description(ctx)
+}
+
+// validateType contains the common validation logic for both string and object validators
+func (validator requiresTypeValidator) validateType(ctx context.Context, config tfsdk.Config, attrPath path.Path, diagnostics *diag.Diagnostics) bool {
+ // Get the type attribute value from the same configuration object
+ var typeAttr *string
+ diags := config.GetAttribute(ctx, path.Root("type"), &typeAttr)
+ diagnostics.Append(diags...)
+ if diagnostics.HasError() {
+ return false
+ }
+
+ // If type is unknown or empty, we can't validate
+ if typeAttr == nil {
+ return true
+ }
+
+ // Check if the current type matches the expected type
+ if *typeAttr != validator.expectedType {
+ diagnostics.AddAttributeError(
+ attrPath,
+ fmt.Sprintf("Attribute not valid for API key type '%s'", *typeAttr),
+ fmt.Sprintf("The %s attribute can only be used when type='%s', but type='%s' was specified.",
+ attrPath.String(), validator.expectedType, *typeAttr),
+ )
+ return false
+ }
+
+ return true
+}
+
+func (validator requiresTypeValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) {
+ // If the attribute is null or unknown, there's nothing to validate
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+
+ validator.validateType(ctx, req.Config, req.Path, &resp.Diagnostics)
+}
+
+func (validator requiresTypeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
+ // If the attribute is null or unknown, there's nothing to validate
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+
+ validator.validateType(ctx, req.Config, req.Path, &resp.Diagnostics)
+}
diff --git a/internal/elasticsearch/security/api_key/validators_test.go b/internal/elasticsearch/security/api_key/validators_test.go
new file mode 100644
index 000000000..b52965177
--- /dev/null
+++ b/internal/elasticsearch/security/api_key/validators_test.go
@@ -0,0 +1,97 @@
+package api_key
+
+import (
+ "context"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/tfsdk"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-go/tftypes"
+)
+
+func TestRequiresTypeValidator(t *testing.T) {
+ t.Parallel()
+
+ type testCase struct {
+ name string
+ typeValue string
+ attrValue string
+ expectError bool
+ }
+
+ testCases := []testCase{
+ {
+ name: "role_descriptors with type=rest should be valid",
+ typeValue: "rest",
+ attrValue: `{"role": {"cluster": ["all"]}}`,
+ expectError: false,
+ },
+ {
+ name: "role_descriptors with type=cross_cluster should be invalid",
+ typeValue: "cross_cluster",
+ attrValue: `{"role": {"cluster": ["all"]}}`,
+ expectError: true,
+ },
+ {
+ name: "null role_descriptors with type=cross_cluster should be valid",
+ typeValue: "cross_cluster",
+ attrValue: "",
+ expectError: false,
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ // Create test config values
+ configValues := map[string]tftypes.Value{
+ "type": tftypes.NewValue(tftypes.String, testCase.typeValue),
+ }
+
+ if testCase.attrValue != "" {
+ configValues["role_descriptors"] = tftypes.NewValue(tftypes.String, testCase.attrValue)
+ } else {
+ configValues["role_descriptors"] = tftypes.NewValue(tftypes.String, nil)
+ }
+
+ config := tfsdk.Config{
+ Raw: tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{
+ "type": tftypes.String,
+ "role_descriptors": tftypes.String,
+ }}, configValues),
+ Schema: schema.Schema{
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{},
+ "role_descriptors": schema.StringAttribute{},
+ },
+ },
+ }
+
+ var configValue types.String
+ if testCase.attrValue != "" {
+ configValue = types.StringValue(testCase.attrValue)
+ } else {
+ configValue = types.StringNull()
+ }
+
+ request := validator.StringRequest{
+ Path: path.Root("role_descriptors"),
+ ConfigValue: configValue,
+ Config: config,
+ }
+
+ response := &validator.StringResponse{}
+ RequiresType("rest").ValidateString(context.Background(), request, response)
+
+ if testCase.expectError && !response.Diagnostics.HasError() {
+ t.Errorf("Expected error but got none")
+ }
+
+ if !testCase.expectError && response.Diagnostics.HasError() {
+ t.Errorf("Expected no error but got: %v", response.Diagnostics)
+ }
+ })
+ }
+}
diff --git a/internal/models/models.go b/internal/models/models.go
index 5608df749..b95efbc64 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -126,12 +126,42 @@ type ApiKeyCreateResponse struct {
type ApiKeyResponse struct {
ApiKey
+ Type string `json:"type,omitempty"`
RolesDescriptors map[string]ApiKeyRoleDescriptor `json:"role_descriptors,omitempty"`
Expiration int64 `json:"expiration,omitempty"`
Id string `json:"id,omitempty"`
Key string `json:"api_key,omitempty"`
EncodedKey string `json:"encoded,omitempty"`
Invalidated bool `json:"invalidated,omitempty"`
+ Access *CrossClusterApiKeyAccess `json:"access,omitempty"`
+}
+
+type CrossClusterApiKeyAccess struct {
+ Search []CrossClusterApiKeyAccessEntry `json:"search,omitempty"`
+ Replication []CrossClusterApiKeyAccessEntry `json:"replication,omitempty"`
+}
+
+type CrossClusterApiKeyAccessEntry struct {
+ Names []string `json:"names"`
+ FieldSecurity *FieldSecurity `json:"field_security,omitempty"`
+ Query *string `json:"query,omitempty"`
+ AllowRestrictedIndices *bool `json:"allow_restricted_indices,omitempty"`
+}
+
+type CrossClusterApiKey struct {
+ ID string `json:"-"`
+ Name string `json:"name,omitempty"`
+ Expiration string `json:"expiration,omitempty"`
+ Access *CrossClusterApiKeyAccess `json:"access,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+}
+
+type CrossClusterApiKeyCreateResponse struct {
+ Id string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Key string `json:"api_key,omitempty"`
+ EncodedKey string `json:"encoded,omitempty"`
+ Expiration int64 `json:"expiration,omitempty"`
}
type IndexPerms struct {