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 {