diff --git a/CHANGELOG.md b/CHANGELOG.md index 711e8de59..d188307bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- Support updating `elasticstack_elasticsearch_security_api_key` when supported by the backing cluster ([#843](https://github.com/elastic/terraform-provider-elasticstack/pull/843)) - Fix validation of `throttle`, and `interval` attributes in `elasticstack_kibana_alerting_rule` allowing all Elastic duration values ([#846](https://github.com/elastic/terraform-provider-elasticstack/pull/846)) - Fix boolean setting parsing for `elasticstack_elasticsearch_indices` data source. ([#842](https://github.com/elastic/terraform-provider-elasticstack/pull/842)) diff --git a/docs/resources/elasticsearch_security_api_key.md b/docs/resources/elasticsearch_security_api_key.md index d039758d2..f4a7a9d93 100644 --- a/docs/resources/elasticsearch_security_api_key.md +++ b/docs/resources/elasticsearch_security_api_key.md @@ -87,7 +87,7 @@ output "api_key" { ### Optional -- `elasticsearch_connection` (Block List, Max: 1, Deprecated) Elasticsearch connection configuration block. This property will be removed in a future provider version. Configure the Elasticsearch connection via the provider configuration instead. (see [below for nested schema](#nestedblock--elasticsearch_connection)) +- `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. diff --git a/internal/clients/elasticsearch/security.go b/internal/clients/elasticsearch/security.go index 5fb8c04d5..d0ece033f 100644 --- a/internal/clients/elasticsearch/security.go +++ b/internal/clients/elasticsearch/security.go @@ -10,6 +10,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" ) @@ -292,57 +293,77 @@ func DeleteRoleMapping(ctx context.Context, apiClient *clients.ApiClient, roleMa return nil } -func PutApiKey(apiClient *clients.ApiClient, apikey *models.ApiKey) (*models.ApiKeyResponse, diag.Diagnostics) { - var diags diag.Diagnostics +func CreateApiKey(apiClient *clients.ApiClient, apikey *models.ApiKey) (*models.ApiKeyCreateResponse, fwdiag.Diagnostics) { apikeyBytes, err := json.Marshal(apikey) if err != nil { - return nil, diag.FromErr(err) + return nil, utils.FrameworkDiagFromError(err) } esClient, err := apiClient.GetESClient() if err != nil { - return nil, diag.FromErr(err) + return nil, utils.FrameworkDiagFromError(err) } res, err := esClient.Security.CreateAPIKey(bytes.NewReader(apikeyBytes)) if err != nil { - return nil, diag.FromErr(err) + return nil, utils.FrameworkDiagFromError(err) } defer res.Body.Close() if diags := utils.CheckError(res, "Unable to create apikey"); diags.HasError() { - return nil, diags + return nil, utils.FrameworkDiagsFromSDK(diags) } - var apiKey models.ApiKeyResponse + var apiKey models.ApiKeyCreateResponse if err := json.NewDecoder(res.Body).Decode(&apiKey); err != nil { - return nil, diag.FromErr(err) + return nil, utils.FrameworkDiagFromError(err) } - return &apiKey, diags + return &apiKey, nil } -func GetApiKey(apiClient *clients.ApiClient, id string) (*models.ApiKeyResponse, diag.Diagnostics) { - var diags diag.Diagnostics +func UpdateApiKey(apiClient *clients.ApiClient, apikey models.ApiKey) 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 nil, diag.FromErr(err) + return utils.FrameworkDiagFromError(err) + } + res, err := esClient.Security.UpdateAPIKey(id, esClient.Security.UpdateAPIKey.WithBody(bytes.NewReader(apikeyBytes))) + if err != nil { + return utils.FrameworkDiagFromError(err) + } + defer res.Body.Close() + if diags := utils.CheckError(res, "Unable to create apikey"); diags.HasError() { + return utils.FrameworkDiagsFromSDK(diags) + } + + return nil +} + +func GetApiKey(apiClient *clients.ApiClient, id string) (*models.ApiKeyResponse, fwdiag.Diagnostics) { + esClient, err := apiClient.GetESClient() + if err != nil { + return nil, utils.FrameworkDiagFromError(err) } req := esClient.Security.GetAPIKey.WithID(id) res, err := esClient.Security.GetAPIKey(req) if err != nil { - return nil, diag.FromErr(err) + return nil, utils.FrameworkDiagFromError(err) } defer res.Body.Close() if res.StatusCode == http.StatusNotFound { - diags := append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Unable to find an apikey in the cluster.", - Detail: fmt.Sprintf("Unable to get apikey: '%s' from the cluster.", id), - }) - return nil, diags + return nil, nil } if diags := utils.CheckError(res, "Unable to get an apikey."); diags.HasError() { - return nil, diags + return nil, utils.FrameworkDiagsFromSDK(diags) } // unmarshal our response to proper type @@ -350,25 +371,23 @@ func GetApiKey(apiClient *clients.ApiClient, id string) (*models.ApiKeyResponse, ApiKeys []models.ApiKeyResponse `json:"api_keys"` } if err := json.NewDecoder(res.Body).Decode(&apiKeys); err != nil { - return nil, diag.FromErr(err) + return nil, utils.FrameworkDiagFromError(err) } if len(apiKeys.ApiKeys) != 1 { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Unable to find an apikey in the cluster", - Detail: fmt.Sprintf(`Unable to find "%s" apikey in the cluster`, id), - }) - return nil, diags + return nil, fwdiag.Diagnostics{ + fwdiag.NewErrorDiagnostic( + "Unable to find an apikey in the cluster", + fmt.Sprintf(`Unable to find "%s" apikey in the cluster`, id), + ), + } } apiKey := apiKeys.ApiKeys[0] - return &apiKey, diags + return &apiKey, nil } -func DeleteApiKey(apiClient *clients.ApiClient, id string) diag.Diagnostics { - var diags diag.Diagnostics - +func DeleteApiKey(apiClient *clients.ApiClient, id string) fwdiag.Diagnostics { apiKeys := struct { Ids []string `json:"ids"` }{ @@ -377,19 +396,19 @@ func DeleteApiKey(apiClient *clients.ApiClient, id string) diag.Diagnostics { apikeyBytes, err := json.Marshal(apiKeys) if err != nil { - return diag.FromErr(err) + return utils.FrameworkDiagFromError(err) } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + return utils.FrameworkDiagFromError(err) } res, err := esClient.Security.InvalidateAPIKey(bytes.NewReader(apikeyBytes)) if err != nil && res.IsError() { - return diag.FromErr(err) + return utils.FrameworkDiagFromError(err) } defer res.Body.Close() if diags := utils.CheckError(res, "Unable to delete an apikey"); diags.HasError() { - return diags + return utils.FrameworkDiagsFromSDK(diags) } - return diags + return nil } diff --git a/internal/clients/fleet/fleet.go b/internal/clients/fleet/fleet.go index 607e74682..1a0905ceb 100644 --- a/internal/clients/fleet/fleet.go +++ b/internal/clients/fleet/fleet.go @@ -8,6 +8,7 @@ import ( "net/http" fleetapi "github.com/elastic/terraform-provider-elasticstack/generated/fleet" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework/diag" ) @@ -19,7 +20,7 @@ var ( func AllEnrollmentTokens(ctx context.Context, client *Client) ([]fleetapi.EnrollmentApiKey, diag.Diagnostics) { resp, err := client.API.GetEnrollmentApiKeysWithResponse(ctx) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } if resp.StatusCode() == http.StatusOK { @@ -38,7 +39,7 @@ func GetEnrollmentTokensByPolicy(ctx context.Context, client *Client, policyID s return nil }) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } if resp.StatusCode() == http.StatusOK { @@ -51,7 +52,7 @@ func GetEnrollmentTokensByPolicy(ctx context.Context, client *Client, policyID s func ReadAgentPolicy(ctx context.Context, client *Client, id string) (*fleetapi.AgentPolicy, diag.Diagnostics) { resp, err := client.API.AgentPolicyInfoWithResponse(ctx, id) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -76,7 +77,7 @@ func CreateAgentPolicy(ctx context.Context, client *Client, req fleetapi.AgentPo return nil }) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -91,7 +92,7 @@ func CreateAgentPolicy(ctx context.Context, client *Client, req fleetapi.AgentPo func UpdateAgentPolicy(ctx context.Context, client *Client, id string, req fleetapi.AgentPolicyUpdateRequest) (*fleetapi.AgentPolicy, diag.Diagnostics) { resp, err := client.API.UpdateAgentPolicyWithResponse(ctx, id, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -110,7 +111,7 @@ func DeleteAgentPolicy(ctx context.Context, client *Client, id string) diag.Diag resp, err := client.API.DeleteAgentPolicyWithResponse(ctx, body) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -127,7 +128,7 @@ func DeleteAgentPolicy(ctx context.Context, client *Client, id string) diag.Diag func ReadOutput(ctx context.Context, client *Client, id string) (*fleetapi.OutputCreateRequest, diag.Diagnostics) { resp, err := client.API.GetOutputWithResponse(ctx, id) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -144,7 +145,7 @@ func ReadOutput(ctx context.Context, client *Client, id string) (*fleetapi.Outpu func CreateOutput(ctx context.Context, client *Client, req fleetapi.PostOutputsJSONRequestBody) (*fleetapi.OutputCreateRequest, diag.Diagnostics) { resp, err := client.API.PostOutputsWithResponse(ctx, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -159,7 +160,7 @@ func CreateOutput(ctx context.Context, client *Client, req fleetapi.PostOutputsJ func UpdateOutput(ctx context.Context, client *Client, id string, req fleetapi.UpdateOutputJSONRequestBody) (*fleetapi.OutputUpdateRequest, diag.Diagnostics) { resp, err := client.API.UpdateOutputWithResponse(ctx, id, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -174,7 +175,7 @@ func UpdateOutput(ctx context.Context, client *Client, id string, req fleetapi.U func DeleteOutput(ctx context.Context, client *Client, id string) diag.Diagnostics { resp, err := client.API.DeleteOutputWithResponse(ctx, id) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -191,7 +192,7 @@ func DeleteOutput(ctx context.Context, client *Client, id string) diag.Diagnosti func ReadFleetServerHost(ctx context.Context, client *Client, id string) (*fleetapi.FleetServerHost, diag.Diagnostics) { resp, err := client.API.GetOneFleetServerHostsWithResponse(ctx, id) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -208,7 +209,7 @@ func ReadFleetServerHost(ctx context.Context, client *Client, id string) (*fleet func CreateFleetServerHost(ctx context.Context, client *Client, req fleetapi.PostFleetServerHostsJSONRequestBody) (*fleetapi.FleetServerHost, diag.Diagnostics) { resp, err := client.API.PostFleetServerHostsWithResponse(ctx, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -223,7 +224,7 @@ func CreateFleetServerHost(ctx context.Context, client *Client, req fleetapi.Pos func UpdateFleetServerHost(ctx context.Context, client *Client, id string, req fleetapi.UpdateFleetServerHostsJSONRequestBody) (*fleetapi.FleetServerHost, diag.Diagnostics) { resp, err := client.API.UpdateFleetServerHostsWithResponse(ctx, id, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -238,7 +239,7 @@ func UpdateFleetServerHost(ctx context.Context, client *Client, id string, req f func DeleteFleetServerHost(ctx context.Context, client *Client, id string) diag.Diagnostics { resp, err := client.API.DeleteFleetServerHostsWithResponse(ctx, id) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -260,7 +261,7 @@ func ReadPackagePolicy(ctx context.Context, client *Client, id string) (*fleetap resp, err := client.API.GetPackagePolicyWithResponse(ctx, id, ¶ms) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -282,7 +283,7 @@ func CreatePackagePolicy(ctx context.Context, client *Client, req fleetapi.Creat resp, err := client.API.CreatePackagePolicyWithResponse(ctx, ¶ms, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -302,7 +303,7 @@ func UpdatePackagePolicy(ctx context.Context, client *Client, id string, req fle resp, err := client.API.UpdatePackagePolicyWithResponse(ctx, id, ¶ms, req) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -318,7 +319,7 @@ func DeletePackagePolicy(ctx context.Context, client *Client, id string, force b params := fleetapi.DeletePackagePolicyParams{Force: &force} resp, err := client.API.DeletePackagePolicyWithResponse(ctx, id, ¶ms) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -337,7 +338,7 @@ func ReadPackage(ctx context.Context, client *Client, name, version string) diag resp, err := client.API.GetPackage(ctx, name, version, ¶ms) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } defer resp.Body.Close() @@ -345,11 +346,11 @@ func ReadPackage(ctx context.Context, client *Client, name, version string) diag case http.StatusOK: return nil case http.StatusNotFound: - return fromErr(ErrPackageNotFound) + return utils.FrameworkDiagFromError(ErrPackageNotFound) default: errData, err := io.ReadAll(resp.Body) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } return reportUnknownError(resp.StatusCode, errData) @@ -366,7 +367,7 @@ func InstallPackage(ctx context.Context, client *Client, name, version string, f resp, err := client.API.InstallPackage(ctx, name, version, ¶ms, body) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } defer resp.Body.Close() @@ -376,7 +377,7 @@ func InstallPackage(ctx context.Context, client *Client, name, version string, f default: errData, err := io.ReadAll(resp.Body) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } return reportUnknownError(resp.StatusCode, errData) @@ -392,7 +393,7 @@ func Uninstall(ctx context.Context, client *Client, name, version string, force resp, err := client.API.DeletePackageWithResponse(ctx, name, version, ¶ms, body) if err != nil { - return fromErr(err) + return utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -413,7 +414,7 @@ func AllPackages(ctx context.Context, client *Client, prerelease bool) ([]fleeta resp, err := client.API.ListAllPackagesWithResponse(ctx, ¶ms) if err != nil { - return nil, fromErr(err) + return nil, utils.FrameworkDiagFromError(err) } switch resp.StatusCode() { @@ -424,16 +425,6 @@ func AllPackages(ctx context.Context, client *Client, prerelease bool) ([]fleeta } } -// fromErr recreates the sdkdiag.FromErr functionality. -func fromErr(err error) diag.Diagnostics { - if err == nil { - return nil - } - return diag.Diagnostics{ - diag.NewErrorDiagnostic(err.Error(), ""), - } -} - func reportUnknownError(statusCode int, body []byte) diag.Diagnostics { return diag.Diagnostics{ diag.NewErrorDiagnostic( diff --git a/internal/elasticsearch/security/api_key.go b/internal/elasticsearch/security/api_key.go deleted file mode 100644 index 45928a0a2..000000000 --- a/internal/elasticsearch/security/api_key.go +++ /dev/null @@ -1,278 +0,0 @@ -package security - -import ( - "context" - "encoding/json" - "regexp" - "strings" - - "github.com/elastic/terraform-provider-elasticstack/internal/clients" - "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" - "github.com/elastic/terraform-provider-elasticstack/internal/models" - "github.com/elastic/terraform-provider-elasticstack/internal/utils" - "github.com/hashicorp/go-version" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" -) - -var APIKeyMinVersion = version.Must(version.NewVersion("8.0.0")) // Enabled in 8.0 -var APIKeyWithRestrictionMinVersion = version.Must(version.NewVersion("8.9.0")) // Enabled in 8.0 - -func ResourceApiKey() *schema.Resource { - apikeySchema := map[string]*schema.Schema{ - "id": { - Description: "Internal identifier of the resource.", - Type: schema.TypeString, - Computed: true, - }, - "key_id": { - Description: "Unique id for this API key.", - Type: schema.TypeString, - Computed: true, - }, - "name": { - Description: "Specifies the name for this API key.", - Type: schema.TypeString, - Required: true, - ForceNew: true, - ValidateFunc: validation.All( - validation.StringLenBetween(1, 1024), - validation.StringMatch(regexp.MustCompile(`^([[:graph:]]| )+$`), "must contain alphanumeric characters (a-z, A-Z, 0-9), spaces, punctuation, and printable symbols in the Basic Latin (ASCII) block. Leading or trailing whitespace is not allowed"), - ), - }, - "role_descriptors": { - Description: "Role descriptors for this API key.", - Type: schema.TypeString, - Optional: true, - ForceNew: true, - ValidateFunc: validation.StringIsJSON, - DiffSuppressFunc: utils.DiffJsonSuppress, - }, - "expiration": { - Description: "Expiration time for the API key. By default, API keys never expire.", - Type: schema.TypeString, - Optional: true, - ForceNew: true, - }, - "expiration_timestamp": { - Description: "Expiration time in milliseconds for the API key. By default, API keys never expire.", - Type: schema.TypeInt, - Computed: true, - }, - "metadata": { - Description: "Arbitrary metadata that you want to associate with the API key.", - Type: schema.TypeString, - Optional: true, - Computed: true, - ForceNew: true, - ValidateFunc: validation.StringIsJSON, - DiffSuppressFunc: utils.DiffJsonSuppress, - }, - "api_key": { - Description: "Generated API Key.", - Type: schema.TypeString, - Sensitive: true, - Computed: true, - }, - "encoded": { - Description: "API key credentials which is the Base64-encoding of the UTF-8 representation of the id and api_key joined by a colon (:).", - Type: schema.TypeString, - Sensitive: true, - Computed: true, - }, - } - - utils.AddConnectionSchema(apikeySchema) - - return &schema.Resource{ - 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", - - CreateContext: resourceSecurityApiKeyCreate, - UpdateContext: resourceSecurityApiKeyUpdate, - ReadContext: resourceSecurityApiKeyRead, - DeleteContext: resourceSecurityApiKeyDelete, - - Schema: apikeySchema, - } -} - -func resourceSecurityApiKeyCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - - nameId := d.Get("name").(string) - - var apikey models.ApiKey - apikey.Name = nameId - - if v, ok := d.GetOk("expiration"); ok { - apikey.Expiration = v.(string) - } - - if v, ok := d.GetOk("role_descriptors"); ok { - role_descriptors := map[string]models.ApiKeyRoleDescriptor{} - if err := json.NewDecoder(strings.NewReader(v.(string))).Decode(&role_descriptors); err != nil { - return diag.FromErr(err) - } - apikey.RolesDescriptors = role_descriptors - - var hasRestriction = false - var keysWithRestrictions []string - - for key, roleDescriptor := range role_descriptors { - if roleDescriptor.Restriction != nil { - hasRestriction = true - keysWithRestrictions = append(keysWithRestrictions, key) - } - } - - if hasRestriction { - isSupported, diags := doesCurrentVersionSupportRestrictionOnApiKey(ctx, client) - - if diags.HasError() { - return diags - } - - if !isSupported { - return diag.Errorf("Specifying `restriction` on an API key role description is not supported in this version of Elasticsearch. Role descriptor(s) %s", strings.Join(keysWithRestrictions, ", ")) - } - } - } - - if v, ok := d.GetOk("metadata"); ok { - metadata := make(map[string]interface{}) - if err := json.NewDecoder(strings.NewReader(v.(string))).Decode(&metadata); err != nil { - return diag.FromErr(err) - } - apikey.Metadata = metadata - } - - putResponse, diags := elasticsearch.PutApiKey(client, &apikey) - - if diags.HasError() { - return diags - } - - id, diags := client.ID(ctx, putResponse.Id) - if diags.HasError() { - return diags - } - - if putResponse.Key != "" { - if err := d.Set("api_key", putResponse.Key); err != nil { - return diag.FromErr(err) - } - } - if putResponse.EncodedKey != "" { - if err := d.Set("encoded", putResponse.EncodedKey); err != nil { - return diag.FromErr(err) - } - } - if err := d.Set("key_id", putResponse.Id); err != nil { - return diag.FromErr(err) - } - if err := d.Set("expiration_timestamp", putResponse.Expiration); err != nil { - return diag.FromErr(err) - } - - if err := d.Set("expiration", apikey.Expiration); err != nil { - return diag.FromErr(err) - } - - d.SetId(id.String()) - return resourceSecurityApiKeyRead(ctx, d, meta) -} - -func doesCurrentVersionSupportRestrictionOnApiKey(ctx context.Context, client *clients.ApiClient) (bool, diag.Diagnostics) { - currentVersion, diags := client.ServerVersion(ctx) - - if diags.HasError() { - return false, diags - } - - return currentVersion.GreaterThanOrEqual(APIKeyWithRestrictionMinVersion), nil -} - -func resourceSecurityApiKeyUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - diags := diag.Diagnostics{diag.Diagnostic{ - Severity: diag.Error, - Summary: `Cannot update API Key`, - Detail: `update not currently supported.`, - }} - - return diags -} - -func resourceSecurityApiKeyRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - compId, diags := clients.CompositeIdFromStr(d.Id()) - if diags.HasError() { - return diags - } - id := compId.ResourceId - - apikey, diags := elasticsearch.GetApiKey(client, id) - if apikey == nil && diags == nil { - d.SetId("") - return diags - } - if diags.HasError() { - return diags - } - - metadata, err := json.Marshal(apikey.Metadata) - if err != nil { - return diag.FromErr(err) - } - - // set the fields - if err := d.Set("name", apikey.Name); err != nil { - return diag.FromErr(err) - } - if err := d.Set("expiration_timestamp", apikey.Expiration); err != nil { - return diag.FromErr(err) - } - if err := d.Set("key_id", apikey.Id); err != nil { - return diag.FromErr(err) - } - - if apikey.RolesDescriptors != nil { - rolesDescriptors, err := json.Marshal(apikey.RolesDescriptors) - if err != nil { - return diag.FromErr(err) - } - if err := d.Set("role_descriptors", string(rolesDescriptors)); err != nil { - return diag.FromErr(err) - } - } - - if err := d.Set("metadata", string(metadata)); err != nil { - return diag.FromErr(err) - } - - return diags -} - -func resourceSecurityApiKeyDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - compId, diags := clients.CompositeIdFromStr(d.Id()) - if diags.HasError() { - return diags - } - - if diags := elasticsearch.DeleteApiKey(client, compId.ResourceId); diags.HasError() { - return diags - } - - d.SetId("") - return diags -} diff --git a/internal/elasticsearch/security/api_key_test.go b/internal/elasticsearch/security/api_key/acc_test.go similarity index 79% rename from internal/elasticsearch/security/api_key_test.go rename to internal/elasticsearch/security/api_key/acc_test.go index 20838770b..dc6cfc12a 100644 --- a/internal/elasticsearch/security/api_key_test.go +++ b/internal/elasticsearch/security/api_key/acc_test.go @@ -1,4 +1,4 @@ -package security_test +package api_key_test import ( "context" @@ -6,6 +6,7 @@ import ( "fmt" "reflect" "regexp" + "strings" "testing" "github.com/hashicorp/go-version" @@ -13,7 +14,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/acctest" "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" - "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/api_key" "github.com/elastic/terraform-provider-elasticstack/internal/models" "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" @@ -32,7 +33,7 @@ func TestAccResourceSecurityApiKey(t *testing.T) { ProtoV6ProviderFactories: acctest.Providers, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.APIKeyMinVersion), + SkipFunc: versionutils.CheckIfVersionIsUnsupported(api_key.MinVersion), Config: testAccResourceSecurityApiKeyCreate(apiKeyName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "name", apiKeyName), @@ -65,11 +66,47 @@ func TestAccResourceSecurityApiKey(t *testing.T) { resource.TestCheckResourceAttrSet("elasticstack_elasticsearch_security_api_key.test", "id"), ), }, + { + SkipFunc: versionutils.CheckIfVersionIsUnsupported(api_key.MinVersionWithUpdate), + Config: testAccResourceSecurityApiKeyUpdate(apiKeyName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "name", apiKeyName), + resource.TestCheckResourceAttrWith("elasticstack_elasticsearch_security_api_key.test", "role_descriptors", func(testValue string) error { + var testRoleDescriptor map[string]models.ApiKeyRoleDescriptor + if err := json.Unmarshal([]byte(testValue), &testRoleDescriptor); err != nil { + return err + } + + expectedRoleDescriptor := map[string]models.ApiKeyRoleDescriptor{ + "role-a": { + Cluster: []string{"manage"}, + Indices: []models.IndexPerms{{ + Names: []string{"index-b*"}, + Privileges: []string{"read"}, + AllowRestrictedIndices: utils.Pointer(false), + }}, + }, + } + + if !reflect.DeepEqual(testRoleDescriptor, expectedRoleDescriptor) { + return fmt.Errorf("%v doesn't match %v", testRoleDescriptor, expectedRoleDescriptor) + } + + return nil + }), + resource.TestCheckResourceAttrSet("elasticstack_elasticsearch_security_api_key.test", "expiration"), + resource.TestCheckResourceAttrSet("elasticstack_elasticsearch_security_api_key.test", "api_key"), + resource.TestCheckResourceAttrSet("elasticstack_elasticsearch_security_api_key.test", "encoded"), + resource.TestCheckResourceAttrSet("elasticstack_elasticsearch_security_api_key.test", "id"), + ), + }, }, }) } func TestAccResourceSecurityApiKeyWithRemoteIndices(t *testing.T) { + minSupportedRemoteIndicesVersion := version.Must(version.NewSemver("8.10.0")) + // generate a random name apiKeyName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) @@ -131,7 +168,7 @@ func TestAccResourceSecurityApiKeyWithWorkflowRestriction(t *testing.T) { ProtoV6ProviderFactories: acctest.Providers, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.APIKeyWithRestrictionMinVersion), + SkipFunc: versionutils.CheckIfVersionIsUnsupported(api_key.MinVersionWithRestriction), Config: testAccResourceSecurityApiKeyCreateWithWorkflowRestriction(apiKeyName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_api_key.test", "name", apiKeyName), @@ -172,6 +209,8 @@ func TestAccResourceSecurityApiKeyWithWorkflowRestriction(t *testing.T) { func TestAccResourceSecurityApiKeyWithWorkflowRestrictionOnElasticPre8_9_x(t *testing.T) { // generate a random name apiKeyName := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + errorPattern := fmt.Sprintf(".*Specifying `restriction` on an API key role description is not supported in this version of Elasticsearch. Role descriptor\\(s\\) %s.*", "role-a") + errorPattern = strings.ReplaceAll(errorPattern, " ", "\\s+") resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(t) }, @@ -179,9 +218,9 @@ func TestAccResourceSecurityApiKeyWithWorkflowRestrictionOnElasticPre8_9_x(t *te ProtoV6ProviderFactories: acctest.Providers, Steps: []resource.TestStep{ { - SkipFunc: SkipWhenApiKeysAreNotSupportedOrRestrictionsAreSupported(security.APIKeyMinVersion, security.APIKeyWithRestrictionMinVersion), + SkipFunc: SkipWhenApiKeysAreNotSupportedOrRestrictionsAreSupported(api_key.MinVersion, api_key.MinVersionWithRestriction), Config: testAccResourceSecurityApiKeyCreateWithWorkflowRestriction(apiKeyName), - ExpectError: regexp.MustCompile(fmt.Sprintf(".*Error: Specifying `restriction` on an API key role description is not supported in this version of Elasticsearch. Role descriptor\\(s\\) %s.*", "role-a")), + ExpectError: regexp.MustCompile(errorPattern), }, }, }) @@ -227,6 +266,31 @@ resource "elasticstack_elasticsearch_security_api_key" "test" { `, apiKeyName) } +func testAccResourceSecurityApiKeyUpdate(apiKeyName string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_api_key" "test" { + name = "%s" + + role_descriptors = jsonencode({ + role-a = { + cluster = ["manage"] + indices = [{ + names = ["index-b*"] + privileges = ["read"] + allow_restricted_indices = false + }] + } + }) + + expiration = "1d" +} + `, apiKeyName) +} + func testAccResourceSecurityApiKeyRemoteIndices(apiKeyName string) string { return fmt.Sprintf(` provider "elasticstack" { diff --git a/internal/elasticsearch/security/api_key/create.go b/internal/elasticsearch/security/api_key/create.go new file mode 100644 index 000000000..da0b95580 --- /dev/null +++ b/internal/elasticsearch/security/api_key/create.go @@ -0,0 +1,107 @@ +package api_key + +import ( + "context" + "fmt" + "strings" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var planModel tfModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, planModel.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + 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 + } + + 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 + } + + finalModel, diags := r.read(ctx, client, planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, *finalModel)...) +} + +func (r Resource) buildApiModel(ctx context.Context, model tfModel, client *clients.ApiClient) (models.ApiKey, diag.Diagnostics) { + apiModel, diags := model.toAPIModel() + if diags.HasError() { + return models.ApiKey{}, diags + } + + hasRestriction := false + keysWithRestrictions := []string{} + for key, descriptor := range apiModel.RolesDescriptors { + if descriptor.Restriction != nil { + hasRestriction = true + keysWithRestrictions = append(keysWithRestrictions, key) + } + } + + if hasRestriction { + isSupported, diags := doesCurrentVersionSupportRestrictionOnApiKey(ctx, client) + if diags.HasError() { + return models.ApiKey{}, diags + } + + if !isSupported { + diags.AddAttributeError( + path.Root("roles_descriptors"), + "Specifying `restriction` on an API key role description is not supported in this version of Elasticsearch", + fmt.Sprintf("Specifying `restriction` on an API key role description is not supported in this version of Elasticsearch. Role descriptor(s) %s", strings.Join(keysWithRestrictions, ", ")), + ) + return models.ApiKey{}, diags + } + } + + return apiModel, nil +} + +func doesCurrentVersionSupportRestrictionOnApiKey(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(MinVersionWithRestriction), nil +} diff --git a/internal/elasticsearch/security/api_key/delete.go b/internal/elasticsearch/security/api_key/delete.go new file mode 100644 index 000000000..57fe7c44b --- /dev/null +++ b/internal/elasticsearch/security/api_key/delete.go @@ -0,0 +1,35 @@ +package api_key + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var stateModel tfModel + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, stateModel.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + compId, diags := stateModel.GetID() + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(elasticsearch.DeleteApiKey(client, compId.ResourceId)...) + if resp.Diagnostics.HasError() { + return + } + + resp.State.RemoveResource(ctx) +} diff --git a/internal/elasticsearch/security/api_key/models.go b/internal/elasticsearch/security/api_key/models.go new file mode 100644 index 000000000..7a9af1ec7 --- /dev/null +++ b/internal/elasticsearch/security/api_key/models.go @@ -0,0 +1,107 @@ +package api_key + +import ( + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +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"` + RoleDescriptors jsontypes.Normalized `tfsdk:"role_descriptors"` + Expiration types.String `tfsdk:"expiration"` + ExpirationTimestamp types.Int64 `tfsdk:"expiration_timestamp"` + Metadata jsontypes.Normalized `tfsdk:"metadata"` + APIKey types.String `tfsdk:"api_key"` + Encoded types.String `tfsdk:"encoded"` +} + +func (model tfModel) GetID() (*clients.CompositeId, diag.Diagnostics) { + compId, sdkDiags := clients.CompositeIdFromStr(model.ID.ValueString()) + if sdkDiags.HasError() { + return nil, utils.FrameworkDiagsFromSDK(sdkDiags) + } + + return compId, nil +} + +func (model tfModel) toAPIModel() (models.ApiKey, diag.Diagnostics) { + apiModel := models.ApiKey{ + 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 + } + } + + if utils.IsKnown(model.RoleDescriptors) { + diags := model.RoleDescriptors.Unmarshal(&apiModel.RolesDescriptors) + if diags.HasError() { + return models.ApiKey{}, diags + } + } + + return apiModel, nil +} + +func (model *tfModel) populateFromCreate(apiKey models.ApiKeyCreateResponse) { + model.KeyID = basetypes.NewStringValue(apiKey.Id) + model.Name = basetypes.NewStringValue(apiKey.Name) + model.APIKey = basetypes.NewStringValue(apiKey.Key) + model.Encoded = basetypes.NewStringValue(apiKey.EncodedKey) +} + +func (model *tfModel) populateFromAPI(apiKey models.ApiKeyResponse, serverVersion *version.Version) diag.Diagnostics { + model.KeyID = basetypes.NewStringValue(apiKey.Id) + model.Name = basetypes.NewStringValue(apiKey.Name) + model.ExpirationTimestamp = basetypes.NewInt64Value(apiKey.Expiration) + model.Metadata = jsontypes.NewNormalizedNull() + + if serverVersion.GreaterThanOrEqual(MinVersionReturningRoleDescriptors) { + model.RoleDescriptors = jsontypes.NewNormalizedNull() + + if apiKey.RolesDescriptors != nil { + descriptors, diags := marshalNormalizedJsonValue(apiKey.RolesDescriptors) + if diags.HasError() { + return diags + } + + model.RoleDescriptors = descriptors + } + } + + if apiKey.Metadata != nil { + metadata, diags := marshalNormalizedJsonValue(apiKey.Metadata) + if diags.HasError() { + return diags + } + + model.Metadata = metadata + } + + return nil +} + +func marshalNormalizedJsonValue(item any) (jsontypes.Normalized, diag.Diagnostics) { + jsonBytes, err := json.Marshal(item) + if err != nil { + return jsontypes.Normalized{}, utils.FrameworkDiagFromError(err) + } + + return jsontypes.NewNormalizedValue(string(jsonBytes)), nil +} diff --git a/internal/elasticsearch/security/api_key/read.go b/internal/elasticsearch/security/api_key/read.go new file mode 100644 index 000000000..2afcf4414 --- /dev/null +++ b/internal/elasticsearch/security/api_key/read.go @@ -0,0 +1,68 @@ +package api_key + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var stateModel tfModel + resp.Diagnostics.Append(req.State.Get(ctx, &stateModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, stateModel.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + finalModel, diags := r.read(ctx, client, stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if finalModel == nil { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, *finalModel)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.saveClusterVersion(ctx, client, resp.Private)...) +} + +func (r *Resource) read(ctx context.Context, client *clients.ApiClient, model tfModel) (*tfModel, diag.Diagnostics) { + var diags diag.Diagnostics + compId, diags := model.GetID() + if diags.HasError() { + return nil, diags + } + + apiKey, diags := elasticsearch.GetApiKey(client, compId.ResourceId) + if diags.HasError() { + return nil, diags + } + if apiKey == nil { + return nil, nil + } + + version, sdkDiags := client.ServerVersion(ctx) + diags = utils.FrameworkDiagsFromSDK(sdkDiags) + if diags.HasError() { + return nil, diags + } + + diags.Append(model.populateFromAPI(*apiKey, version)...) + return &model, diags +} diff --git a/internal/elasticsearch/security/api_key/resource.go b/internal/elasticsearch/security/api_key/resource.go new file mode 100644 index 000000000..3405fd30b --- /dev/null +++ b/internal/elasticsearch/security/api_key/resource.go @@ -0,0 +1,115 @@ +package api_key + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &Resource{} +var _ resource.ResourceWithConfigure = &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 +) + +type Resource struct { + client *clients.ApiClient +} + +var configuredResources = []*Resource{} + +func (r *Resource) Configure(ctx context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(request.ProviderData) + response.Diagnostics.Append(diags...) + r.client = client + configuredResources = append(configuredResources, r) +} + +func (r *Resource) Metadata(ctx context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = request.ProviderTypeName + "_elasticsearch_security_api_key" +} + +// Equivalent to privatestate.ProviderData +type privateData interface { + // GetKey returns the private state data associated with the given key. + // + // If the key is reserved for framework usage, an error diagnostic + // is returned. If the key is valid, but private state data is not found, + // nil is returned. + // + // The naming of keys only matters in context of a single resource, + // however care should be taken that any historical keys are not reused + // without accounting for older resource instances that may still have + // older data at the key. + GetKey(ctx context.Context, key string) ([]byte, diag.Diagnostics) + + // SetKey sets the private state data at the given key. + // + // If the key is reserved for framework usage, an error diagnostic + // is returned. The data must be valid JSON and UTF-8 safe or an error + // diagnostic is returned. + // + // The naming of keys only matters in context of a single resource, + // however care should be taken that any historical keys are not reused + // without accounting for older resource instances that may still have + // older data at the key. + SetKey(ctx context.Context, key string, value []byte) diag.Diagnostics +} + +const clusterVersionPrivateDataKey = "cluster-version" + +type clusterVersionPrivateData struct { + Version string +} + +func (r *Resource) saveClusterVersion(ctx context.Context, client *clients.ApiClient, priv privateData) diag.Diagnostics { + version, sdkDiags := client.ServerVersion(ctx) + diags := utils.FrameworkDiagsFromSDK(sdkDiags) + if diags.HasError() { + return diags + } + + data, err := json.Marshal(clusterVersionPrivateData{Version: version.String()}) + if err != nil { + diags.AddError("failed to marshal cluster version data", err.Error()) + return diags + } + + diags.Append(priv.SetKey(ctx, clusterVersionPrivateDataKey, data)...) + return diags +} + +func (r *Resource) clusterVersionOfLastRead(ctx context.Context, priv privateData) (*version.Version, diag.Diagnostics) { + versionBytes, diags := priv.GetKey(ctx, clusterVersionPrivateDataKey) + if diags.HasError() { + return nil, diags + } + + if versionBytes == nil { + return nil, nil + } + + var data clusterVersionPrivateData + err := json.Unmarshal(versionBytes, &data) + if err != nil { + diags.AddError("failed to parse private data json", err.Error()) + return nil, diags + } + + v, err := version.NewVersion(data.Version) + if err != nil { + diags.AddError("failed to parse stored cluster version", err.Error()) + return nil, diags + } + + return v, diags +} diff --git a/internal/elasticsearch/security/api_key/schema.go b/internal/elasticsearch/security/api_key/schema.go new file mode 100644 index 000000000..5a5895fe0 --- /dev/null +++ b/internal/elasticsearch/security/api_key/schema.go @@ -0,0 +1,119 @@ +package api_key + +import ( + "context" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "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" + + providerschema "github.com/elastic/terraform-provider-elasticstack/internal/schema" +) + +func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = r.getSchema() +} + +func (r *Resource) getSchema() schema.Schema { + return schema.Schema{ + 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", + Blocks: map[string]schema.Block{ + "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false), + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Internal identifier of the resource.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "key_id": schema.StringAttribute{ + Description: "Unique id for this API key.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "Specifies the name for this API key.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 1024), + stringvalidator.RegexMatches(regexp.MustCompile(`^([[:graph:]]| )+$`), "must contain alphanumeric characters (a-z, A-Z, 0-9), spaces, punctuation, and printable symbols in the Basic Latin (ASCII) block. Leading or trailing whitespace is not allowed"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "role_descriptors": schema.StringAttribute{ + Description: "Role descriptors for this API key.", + CustomType: jsontypes.NormalizedType{}, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + r.requiresReplaceIfUpdateNotSupported(), + }, + }, + "expiration": schema.StringAttribute{ + Description: "Expiration time for the API key. By default, API keys never expire.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "expiration_timestamp": schema.Int64Attribute{ + Description: "Expiration time in milliseconds for the API key. By default, API keys never expire.", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "metadata": schema.StringAttribute{ + Description: "Arbitrary metadata that you want to associate with the API key.", + Optional: true, + Computed: true, + CustomType: jsontypes.NormalizedType{}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + r.requiresReplaceIfUpdateNotSupported(), + }, + }, + "api_key": schema.StringAttribute{ + Description: "Generated API Key.", + Sensitive: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "encoded": schema.StringAttribute{ + Description: "API key credentials which is the Base64-encoding of the UTF-8 representation of the id and api_key joined by a colon (:).", + Sensitive: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *Resource) requiresReplaceIfUpdateNotSupported() planmodifier.String { + return stringplanmodifier.RequiresReplaceIf( + func(ctx context.Context, res planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + version, _ := r.clusterVersionOfLastRead(ctx, res.Private) + + resp.RequiresReplace = version != nil && version.LessThan(MinVersionWithUpdate) + }, + "Requires replace if the server does not support update", + "Requries replace if the server does not support update", + ) +} diff --git a/internal/elasticsearch/security/api_key/update.go b/internal/elasticsearch/security/api_key/update.go new file mode 100644 index 000000000..6cd1d3cb1 --- /dev/null +++ b/internal/elasticsearch/security/api_key/update.go @@ -0,0 +1,42 @@ +package api_key + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var planModel tfModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, planModel.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + apiModel, diags := r.buildApiModel(ctx, planModel, client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(elasticsearch.UpdateApiKey(client, apiModel)...) + if resp.Diagnostics.HasError() { + return + } + + finalModel, diags := r.read(ctx, client, planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, *finalModel)...) +} diff --git a/internal/models/models.go b/internal/models/models.go index a19f74a61..29882b308 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -110,12 +110,20 @@ type RoleMapping struct { } type ApiKey struct { - Name string `json:"name"` + ID string `json:"-"` + Name string `json:"name,omitempty"` RolesDescriptors map[string]ApiKeyRoleDescriptor `json:"role_descriptors,omitempty"` Expiration string `json:"expiration,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } +type ApiKeyCreateResponse struct { + Id string `json:"id,omitempty"` + Name string `json:"name"` + Key string `json:"api_key,omitempty"` + EncodedKey string `json:"encoded,omitempty"` +} + type ApiKeyResponse struct { ApiKey RolesDescriptors map[string]ApiKeyRoleDescriptor `json:"role_descriptors,omitempty"` diff --git a/internal/utils/diag.go b/internal/utils/diag.go new file mode 100644 index 000000000..4a2b158b5 --- /dev/null +++ b/internal/utils/diag.go @@ -0,0 +1,103 @@ +package utils + +import ( + "fmt" + "io" + "net/http" + + "github.com/elastic/go-elasticsearch/v8/esapi" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" + sdkdiag "github.com/hashicorp/terraform-plugin-sdk/v2/diag" +) + +func ConvertSDKDiagnosticsToFramework(sdkDiags sdkdiag.Diagnostics) fwdiag.Diagnostics { + var fwDiags fwdiag.Diagnostics + + for _, sdkDiag := range sdkDiags { + if sdkDiag.Severity == sdkdiag.Error { + fwDiags.AddError(sdkDiag.Summary, sdkDiag.Detail) + } else { + fwDiags.AddWarning(sdkDiag.Summary, sdkDiag.Detail) + } + } + + return fwDiags +} + +func CheckError(res *esapi.Response, errMsg string) sdkdiag.Diagnostics { + var diags sdkdiag.Diagnostics + + if res.IsError() { + body, err := io.ReadAll(res.Body) + if err != nil { + return sdkdiag.FromErr(err) + } + diags = append(diags, sdkdiag.Diagnostic{ + Severity: sdkdiag.Error, + Summary: errMsg, + Detail: fmt.Sprintf("Failed with: %s", body), + }) + return diags + } + return diags +} + +func CheckHttpError(res *http.Response, errMsg string) sdkdiag.Diagnostics { + var diags sdkdiag.Diagnostics + + if res.StatusCode >= 400 { + body, err := io.ReadAll(res.Body) + if err != nil { + return sdkdiag.FromErr(err) + } + diags = append(diags, sdkdiag.Diagnostic{ + Severity: sdkdiag.Error, + Summary: errMsg, + Detail: fmt.Sprintf("Failed with: %s", body), + }) + return diags + } + return diags +} + +func CheckHttpErrorFromFW(res *http.Response, errMsg string) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics + + if res.StatusCode >= 400 { + body, err := io.ReadAll(res.Body) + if err != nil { + diags.AddError(errMsg, err.Error()) + return diags + } + diags.AddError(errMsg, fmt.Sprintf("Failed with: %s", body)) + return diags + } + return diags +} + +func FrameworkDiagsFromSDK(sdkDiags sdkdiag.Diagnostics) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics + + for _, sdkDiag := range sdkDiags { + var fwDiag fwdiag.Diagnostic + + if sdkDiag.Severity == sdkdiag.Error { + fwDiag = fwdiag.NewErrorDiagnostic(sdkDiag.Summary, sdkDiag.Detail) + } else { + fwDiag = fwdiag.NewWarningDiagnostic(sdkDiag.Summary, sdkDiag.Detail) + } + + diags.Append(fwDiag) + } + + return diags +} + +func FrameworkDiagFromError(err error) fwdiag.Diagnostics { + if err == nil { + return nil + } + return fwdiag.Diagnostics{ + fwdiag.NewErrorDiagnostic(err.Error(), ""), + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 8368617c6..a5f8ddc16 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -5,13 +5,10 @@ import ( "crypto/sha1" "encoding/json" "fmt" - "io" - "net/http" "reflect" "strings" "time" - "github.com/elastic/go-elasticsearch/v8/esapi" providerSchema "github.com/elastic/terraform-provider-elasticstack/internal/schema" "github.com/hashicorp/terraform-plugin-framework/diag" fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" @@ -21,89 +18,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func ConvertSDKDiagnosticsToFramework(sdkDiags sdkdiag.Diagnostics) fwdiag.Diagnostics { - var fwDiags fwdiag.Diagnostics - - for _, sdkDiag := range sdkDiags { - if sdkDiag.Severity == sdkdiag.Error { - fwDiags.AddError(sdkDiag.Summary, sdkDiag.Detail) - } else { - fwDiags.AddWarning(sdkDiag.Summary, sdkDiag.Detail) - } - } - - return fwDiags -} - -func CheckError(res *esapi.Response, errMsg string) sdkdiag.Diagnostics { - var diags sdkdiag.Diagnostics - - if res.IsError() { - body, err := io.ReadAll(res.Body) - if err != nil { - return sdkdiag.FromErr(err) - } - diags = append(diags, sdkdiag.Diagnostic{ - Severity: sdkdiag.Error, - Summary: errMsg, - Detail: fmt.Sprintf("Failed with: %s", body), - }) - return diags - } - return diags -} - -func CheckHttpError(res *http.Response, errMsg string) sdkdiag.Diagnostics { - var diags sdkdiag.Diagnostics - - if res.StatusCode >= 400 { - body, err := io.ReadAll(res.Body) - if err != nil { - return sdkdiag.FromErr(err) - } - diags = append(diags, sdkdiag.Diagnostic{ - Severity: sdkdiag.Error, - Summary: errMsg, - Detail: fmt.Sprintf("Failed with: %s", body), - }) - return diags - } - return diags -} - -func CheckHttpErrorFromFW(res *http.Response, errMsg string) fwdiag.Diagnostics { - var diags fwdiag.Diagnostics - - if res.StatusCode >= 400 { - body, err := io.ReadAll(res.Body) - if err != nil { - diags.AddError(errMsg, err.Error()) - return diags - } - diags.AddError(errMsg, fmt.Sprintf("Failed with: %s", body)) - return diags - } - return diags -} - -func FrameworkDiagsFromSDK(sdkDiags sdkdiag.Diagnostics) fwdiag.Diagnostics { - var diags fwdiag.Diagnostics - - for _, sdkDiag := range sdkDiags { - var fwDiag fwdiag.Diagnostic - - if sdkDiag.Severity == sdkdiag.Error { - fwDiag = fwdiag.NewErrorDiagnostic(sdkDiag.Summary, sdkDiag.Detail) - } else { - fwDiag = fwdiag.NewWarningDiagnostic(sdkDiag.Summary, sdkDiag.Detail) - } - - diags.Append(fwDiag) - } - - return diags -} - // Compares the JSON in two byte slices func JSONBytesEqual(a, b []byte) (bool, error) { var j, j2 interface{} diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 40df41f6a..994d494ec 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -7,6 +7,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients/config" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/index" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/index/indices" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/api_key" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/agent_policy" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/enrollment_tokens" "github.com/elastic/terraform-provider-elasticstack/internal/fleet/integration" @@ -91,6 +92,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { func() resource.Resource { return &private_location.Resource{} }, func() resource.Resource { return &index.Resource{} }, func() resource.Resource { return &synthetics.Resource{} }, + func() resource.Resource { return &api_key.Resource{} }, agent_policy.NewResource, integration.NewResource, integration_policy.NewResource, diff --git a/provider/provider.go b/provider/provider.go index 4a7c9c69f..105ee5067 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -90,7 +90,6 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_index_template": index.ResourceTemplate(), "elasticstack_elasticsearch_ingest_pipeline": ingest.ResourceIngestPipeline(), "elasticstack_elasticsearch_logstash_pipeline": logstash.ResourceLogstashPipeline(), - "elasticstack_elasticsearch_security_api_key": security.ResourceApiKey(), "elasticstack_elasticsearch_security_role": security.ResourceRole(), "elasticstack_elasticsearch_security_role_mapping": security.ResourceRoleMapping(), "elasticstack_elasticsearch_security_user": security.ResourceUser(), diff --git a/provider/provider_test.go b/provider/provider_test.go index 9a4c88b52..112e5e1ca 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -8,7 +8,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/acctest" "github.com/elastic/terraform-provider-elasticstack/internal/clients/config" - "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/api_key" "github.com/elastic/terraform-provider-elasticstack/internal/versionutils" "github.com/elastic/terraform-provider-elasticstack/provider" "github.com/hashicorp/go-version" @@ -31,7 +31,7 @@ func TestElasticsearchAPIKeyConnection(t *testing.T) { ProtoV6ProviderFactories: acctest.Providers, Steps: []resource.TestStep{ { - SkipFunc: versionutils.CheckIfVersionIsUnsupported(security.APIKeyMinVersion), + SkipFunc: versionutils.CheckIfVersionIsUnsupported(api_key.MinVersion), Config: testElasticsearchConnection(apiKeyName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("data.elasticstack_elasticsearch_security_user.test", "username", "elastic"),