From 0064cad4a226c89a818812b6077ec81fd0c5eef1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:54:01 +0000 Subject: [PATCH 1/8] Initial plan From 0af1717d7fb628df48905ca203dffc4bdcdcc81f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:27:13 +0000 Subject: [PATCH 2/8] Migrate elasticsearch_security_user from SDKv2 to Plugin Framework Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- internal/elasticsearch/security/user.go | 227 ------------------ .../{user_test.go => user/acc_test.go} | 34 ++- .../elasticsearch/security/user/create.go | 15 ++ .../elasticsearch/security/user/delete.go | 33 +++ .../elasticsearch/security/user/models.go | 19 ++ internal/elasticsearch/security/user/read.go | 76 ++++++ .../security/user/resource-description.md | 23 ++ .../elasticsearch/security/user/resource.go | 31 +++ .../elasticsearch/security/user/schema.go | 101 ++++++++ .../elasticsearch/security/user/update.go | 128 ++++++++++ .../elasticsearch/security/user_shared.go | 61 +++++ provider/plugin_framework.go | 2 + provider/provider.go | 1 - 13 files changed, 522 insertions(+), 229 deletions(-) delete mode 100644 internal/elasticsearch/security/user.go rename internal/elasticsearch/security/{user_test.go => user/acc_test.go} (85%) create mode 100644 internal/elasticsearch/security/user/create.go create mode 100644 internal/elasticsearch/security/user/delete.go create mode 100644 internal/elasticsearch/security/user/models.go create mode 100644 internal/elasticsearch/security/user/read.go create mode 100644 internal/elasticsearch/security/user/resource-description.md create mode 100644 internal/elasticsearch/security/user/resource.go create mode 100644 internal/elasticsearch/security/user/schema.go create mode 100644 internal/elasticsearch/security/user/update.go create mode 100644 internal/elasticsearch/security/user_shared.go diff --git a/internal/elasticsearch/security/user.go b/internal/elasticsearch/security/user.go deleted file mode 100644 index f573a6884..000000000 --- a/internal/elasticsearch/security/user.go +++ /dev/null @@ -1,227 +0,0 @@ -package security - -import ( - "context" - "encoding/json" - "fmt" - "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/terraform-plugin-log/tflog" - "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" -) - -func ResourceUser() *schema.Resource { - userSchema := map[string]*schema.Schema{ - "id": { - Description: "Internal identifier of the resource", - Type: schema.TypeString, - Computed: true, - }, - "username": { - Description: "An identifier for the user see the [security API put user documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html#security-api-put-user-path-params) for more details.", - 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"), - ), - }, - "password": { - Description: "The user’s password. Passwords must be at least 6 characters long.", - Type: schema.TypeString, - Optional: true, - Sensitive: true, - ValidateFunc: validation.StringLenBetween(6, 128), - ConflictsWith: []string{"password_hash"}, - }, - "password_hash": { - Description: "A hash of the user’s password. This must be produced using the same hashing algorithm as has been configured for password storage (see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#hashing-settings).", - Type: schema.TypeString, - Optional: true, - Sensitive: true, - ValidateFunc: validation.StringLenBetween(6, 128), - ConflictsWith: []string{"password"}, - }, - "full_name": { - Description: "The full name of the user.", - Type: schema.TypeString, - Optional: true, - Default: "", - }, - "email": { - Description: "The email of the user.", - Type: schema.TypeString, - Optional: true, - Default: "", - }, - "roles": { - Description: "A set of roles the user has. The roles determine the user’s access permissions. Default is [].", - Type: schema.TypeSet, - Required: true, - MinItems: 1, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - }, - "metadata": { - Description: "Arbitrary metadata that you want to associate with the user.", - Type: schema.TypeString, - Optional: true, - Computed: true, - ValidateFunc: validation.StringIsJSON, - DiffSuppressFunc: utils.DiffJsonSuppress, - }, - "enabled": { - Description: "Specifies whether the user is enabled. The default value is true.", - Type: schema.TypeBool, - Optional: true, - Default: true, - }, - } - - utils.AddConnectionSchema(userSchema) - - return &schema.Resource{ - Description: "Adds and updates users in the native realm. These users are commonly referred to as native users. See the [security user API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html) for more details.", - - CreateContext: resourceSecurityUserPut, - UpdateContext: resourceSecurityUserPut, - ReadContext: resourceSecurityUserRead, - DeleteContext: resourceSecurityUserDelete, - - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - - Schema: userSchema, - } -} - -func resourceSecurityUserPut(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client, diags := clients.NewApiClientFromSDKResource(d, meta) - if diags.HasError() { - return diags - } - usernameId := d.Get("username").(string) - id, diags := client.ID(ctx, usernameId) - if diags.HasError() { - return diags - } - - var user models.User - user.Username = usernameId - - if v, ok := d.GetOk("password"); ok && d.HasChange("password") { - password := v.(string) - user.Password = &password - } - if v, ok := d.GetOk("password_hash"); ok && d.HasChange("password_hash") { - pass_hash := v.(string) - user.PasswordHash = &pass_hash - } - - if v, ok := d.GetOk("email"); ok { - user.Email = v.(string) - } - if v, ok := d.GetOk("full_name"); ok { - user.FullName = v.(string) - } - user.Enabled = d.Get("enabled").(bool) - - roles := make([]string, 0) - if v, ok := d.GetOk("roles"); ok { - for _, role := range v.(*schema.Set).List() { - roles = append(roles, role.(string)) - } - } - user.Roles = roles - - 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) - } - user.Metadata = metadata - } - - if diags := elasticsearch.PutUser(ctx, client, &user); diags.HasError() { - return diags - } - - d.SetId(id.String()) - return resourceSecurityUserRead(ctx, d, meta) -} - -func resourceSecurityUserRead(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 - } - usernameId := compId.ResourceId - - user, diags := elasticsearch.GetUser(ctx, client, usernameId) - if user == nil && diags == nil { - tflog.Warn(ctx, fmt.Sprintf(`User "%s" not found, removing from state`, compId.ResourceId)) - d.SetId("") - return diags - } - if diags.HasError() { - return diags - } - - metadata, err := json.Marshal(user.Metadata) - if err != nil { - return diag.FromErr(err) - } - - // set the fields - if err := d.Set("username", usernameId); err != nil { - return diag.FromErr(err) - } - if err := d.Set("email", user.Email); err != nil { - return diag.FromErr(err) - } - if err := d.Set("full_name", user.FullName); err != nil { - return diag.FromErr(err) - } - if err := d.Set("roles", user.Roles); err != nil { - return diag.FromErr(err) - } - if err := d.Set("metadata", string(metadata)); err != nil { - return diag.FromErr(err) - } - if err := d.Set("enabled", user.Enabled); err != nil { - return diag.FromErr(err) - } - - return diags -} - -func resourceSecurityUserDelete(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.DeleteUser(ctx, client, compId.ResourceId); diags.HasError() { - return diags - } - - return diags -} diff --git a/internal/elasticsearch/security/user_test.go b/internal/elasticsearch/security/user/acc_test.go similarity index 85% rename from internal/elasticsearch/security/user_test.go rename to internal/elasticsearch/security/user/acc_test.go index 13cbd1c11..437725659 100644 --- a/internal/elasticsearch/security/user_test.go +++ b/internal/elasticsearch/security/user/acc_test.go @@ -1,4 +1,4 @@ -package security_test +package user_test import ( "context" @@ -160,6 +160,38 @@ func TestAccImportedUserDoesNotResetPassword(t *testing.T) { }) } +func TestAccResourceSecurityUserFromSDK(t *testing.T) { + username := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + // Create the user with the last provider version where the user resource was built on the SDK + ExternalProviders: map[string]resource.ExternalProvider{ + "elasticstack": { + Source: "elastic/elasticstack", + VersionConstraint: "0.12.1", + }, + }, + Config: testAccResourceSecurityUserCreate(username), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_user.test", "username", username), + resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_user.test", "roles.*", "kibana_user"), + ), + }, + { + ProtoV6ProviderFactories: acctest.Providers, + Config: testAccResourceSecurityUserCreate(username), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_user.test", "username", username), + resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_user.test", "roles.*", "kibana_user"), + ), + }, + }, + }) +} + func testAccResourceSecurityUserCreate(username string) string { return fmt.Sprintf(` provider "elasticstack" { diff --git a/internal/elasticsearch/security/user/create.go b/internal/elasticsearch/security/user/create.go new file mode 100644 index 000000000..748c021c7 --- /dev/null +++ b/internal/elasticsearch/security/user/create.go @@ -0,0 +1,15 @@ +package user + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + diags := r.update(ctx, req.Plan, &resp.State) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/internal/elasticsearch/security/user/delete.go b/internal/elasticsearch/security/user/delete.go new file mode 100644 index 000000000..de4a3b85b --- /dev/null +++ b/internal/elasticsearch/security/user/delete.go @@ -0,0 +1,33 @@ +package user + +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/diagutil" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data UserData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + sdkDiags := elasticsearch.DeleteUser(ctx, client, compId.ResourceId) + resp.Diagnostics.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) +} diff --git a/internal/elasticsearch/security/user/models.go b/internal/elasticsearch/security/user/models.go new file mode 100644 index 000000000..83c4fdd5c --- /dev/null +++ b/internal/elasticsearch/security/user/models.go @@ -0,0 +1,19 @@ +package user + +import ( + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type UserData struct { + Id types.String `tfsdk:"id"` + ElasticsearchConnection types.List `tfsdk:"elasticsearch_connection"` + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + PasswordHash types.String `tfsdk:"password_hash"` + FullName types.String `tfsdk:"full_name"` + Email types.String `tfsdk:"email"` + Roles types.Set `tfsdk:"roles"` + Metadata jsontypes.Normalized `tfsdk:"metadata"` + Enabled types.Bool `tfsdk:"enabled"` +} diff --git a/internal/elasticsearch/security/user/read.go b/internal/elasticsearch/security/user/read.go new file mode 100644 index 000000000..a4ac1c147 --- /dev/null +++ b/internal/elasticsearch/security/user/read.go @@ -0,0 +1,76 @@ +package user + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data UserData + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + compId, diags := clients.CompositeIdFromStrFw(data.Id.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + usernameId := compId.ResourceId + + client, diags := clients.MaybeNewApiClientFromFrameworkResource(ctx, data.ElasticsearchConnection, r.client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + user, sdkDiags := elasticsearch.GetUser(ctx, client, usernameId) + resp.Diagnostics.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + if resp.Diagnostics.HasError() { + return + } + + if user == nil { + tflog.Warn(ctx, fmt.Sprintf(`User "%s" not found, removing from state`, compId.ResourceId)) + resp.State.RemoveResource(ctx) + return + } + + // Set the fields + data.Username = types.StringValue(usernameId) + data.Email = types.StringValue(user.Email) + data.FullName = types.StringValue(user.FullName) + data.Enabled = types.BoolValue(user.Enabled) + + // Handle metadata + if user.Metadata != nil && len(user.Metadata) > 0 { + metadata, err := json.Marshal(user.Metadata) + if err != nil { + resp.Diagnostics.AddError("Failed to marshal metadata", err.Error()) + return + } + data.Metadata = jsontypes.NewNormalizedValue(string(metadata)) + } else { + data.Metadata = jsontypes.NewNormalizedNull() + } + + // Convert roles slice to set + rolesSet, diags := types.SetValueFrom(ctx, types.StringType, user.Roles) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + data.Roles = rolesSet + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/elasticsearch/security/user/resource-description.md b/internal/elasticsearch/security/user/resource-description.md new file mode 100644 index 000000000..b7e72ba28 --- /dev/null +++ b/internal/elasticsearch/security/user/resource-description.md @@ -0,0 +1,23 @@ +--- +subcategory: "Elasticsearch" +layout: "" +page_title: "Elasticstack: elasticstack_elasticsearch_security_user Resource" +description: |- + Adds and updates users in the native realm. These users are commonly referred to as native users. +--- + +# Resource: elasticstack_elasticsearch_security_user + +Adds and updates users in the native realm. These users are commonly referred to as native users. See the [Elasticsearch security user API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html) for more details. + +## Example Usage + +```terraform +resource "elasticstack_elasticsearch_security_user" "user" { + username = "my_user" + password = "changeme" + roles = ["superuser"] + full_name = "John Doe" + email = "john@example.com" +} +``` diff --git a/internal/elasticsearch/security/user/resource.go b/internal/elasticsearch/security/user/resource.go new file mode 100644 index 000000000..c67758837 --- /dev/null +++ b/internal/elasticsearch/security/user/resource.go @@ -0,0 +1,31 @@ +package user + +import ( + "context" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +func NewUserResource() resource.Resource { + return &userResource{} +} + +type userResource struct { + client *clients.ApiClient +} + +func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_elasticsearch_security_user" +} + +func (r *userResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := clients.ConvertProviderData(req.ProviderData) + resp.Diagnostics.Append(diags...) + r.client = client +} + +func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/elasticsearch/security/user/schema.go b/internal/elasticsearch/security/user/schema.go new file mode 100644 index 000000000..47bc51b19 --- /dev/null +++ b/internal/elasticsearch/security/user/schema.go @@ -0,0 +1,101 @@ +package user + +import ( + "context" + _ "embed" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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" +) + +//go:embed resource-description.md +var userResourceDescription string + +func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = GetSchema() +} + +func GetSchema() schema.Schema { + return schema.Schema{ + MarkdownDescription: userResourceDescription, + Blocks: map[string]schema.Block{ + "elasticsearch_connection": providerschema.GetEsFWConnectionBlock("elasticsearch_connection", false), + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Internal identifier of the resource", + Computed: true, + }, + "username": schema.StringAttribute{ + MarkdownDescription: "An identifier for the user (see the [security API put user documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html#security-api-put-user-path-params) for more details).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + 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"), + }, + }, + "password": schema.StringAttribute{ + MarkdownDescription: "The user's password. Passwords must be at least 6 characters long.", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(6, 128), + stringvalidator.ConflictsWith(path.MatchRoot("password_hash")), + }, + }, + "password_hash": schema.StringAttribute{ + MarkdownDescription: "A hash of the user's password. This must be produced using the same hashing algorithm as has been configured for password storage (see the [security settings documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#hashing-settings)).", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(6, 128), + stringvalidator.ConflictsWith(path.MatchRoot("password")), + }, + }, + "full_name": schema.StringAttribute{ + MarkdownDescription: "The full name of the user.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "email": schema.StringAttribute{ + MarkdownDescription: "The email of the user.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "roles": schema.SetAttribute{ + MarkdownDescription: "A set of roles the user has. The roles determine the user's access permissions.", + Required: true, + ElementType: types.StringType, + }, + "metadata": schema.StringAttribute{ + MarkdownDescription: "Arbitrary metadata that you want to associate with the user.", + CustomType: jsontypes.NormalizedType{}, + Optional: true, + Computed: true, + }, + "enabled": schema.BoolAttribute{ + MarkdownDescription: "Specifies whether the user is enabled. The default value is true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + }, + } +} diff --git a/internal/elasticsearch/security/user/update.go b/internal/elasticsearch/security/user/update.go new file mode 100644 index 000000000..ff984f921 --- /dev/null +++ b/internal/elasticsearch/security/user/update.go @@ -0,0 +1,128 @@ +package user + +import ( + "context" + "encoding/json" + "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/diagutil" + "github.com/elastic/terraform-provider-elasticstack/internal/models" + "github.com/elastic/terraform-provider-elasticstack/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State) diag.Diagnostics { + var planData UserData + var diags diag.Diagnostics + diags.Append(plan.Get(ctx, &planData)...) + if diags.HasError() { + return diags + } + + // Check if we have existing state (this is an update, not a create) + hasState := false + var stateData UserData + if state != nil { + if getRaw := state.Raw; !getRaw.IsNull() { + hasState = true + diags.Append(state.Get(ctx, &stateData)...) + if diags.HasError() { + return diags + } + } + } + + usernameId := planData.Username.ValueString() + id, sdkDiags := r.client.ID(ctx, usernameId) + diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + if diags.HasError() { + return diags + } + + client, connDiags := clients.MaybeNewApiClientFromFrameworkResource(ctx, planData.ElasticsearchConnection, r.client) + diags.Append(connDiags...) + if diags.HasError() { + return diags + } + + var user models.User + user.Username = usernameId + + // Only set password if it's in the plan AND (it's a create OR it has changed from state) + if utils.IsKnown(planData.Password) && (!hasState || !planData.Password.Equal(stateData.Password)) { + password := planData.Password.ValueString() + user.Password = &password + } + if utils.IsKnown(planData.PasswordHash) && (!hasState || !planData.PasswordHash.Equal(stateData.PasswordHash)) { + passwordHash := planData.PasswordHash.ValueString() + user.PasswordHash = &passwordHash + } + + if utils.IsKnown(planData.Email) { + user.Email = planData.Email.ValueString() + } + if utils.IsKnown(planData.FullName) { + user.FullName = planData.FullName.ValueString() + } + user.Enabled = planData.Enabled.ValueBool() + + roles := make([]string, 0, len(planData.Roles.Elements())) + diags.Append(planData.Roles.ElementsAs(ctx, &roles, false)...) + if diags.HasError() { + return diags + } + user.Roles = roles + + if !planData.Metadata.IsNull() && !planData.Metadata.IsUnknown() { + metadata := make(map[string]interface{}) + if err := json.NewDecoder(strings.NewReader(planData.Metadata.ValueString())).Decode(&metadata); err != nil { + diags.AddError("Failed to decode metadata", err.Error()) + return diags + } + user.Metadata = metadata + } + + sdkDiags = elasticsearch.PutUser(ctx, client, &user) + diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + if diags.HasError() { + return diags + } + + // Read the user back to get computed fields like metadata + readUser, sdkDiags := elasticsearch.GetUser(ctx, client, usernameId) + diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + if diags.HasError() { + return diags + } + + planData.Id = types.StringValue(id.String()) + + // Set computed fields from the API response + if readUser.Metadata != nil && len(readUser.Metadata) > 0 { + metadata, err := json.Marshal(readUser.Metadata) + if err != nil { + diags.AddError("Failed to marshal metadata", err.Error()) + return diags + } + planData.Metadata = jsontypes.NewNormalizedValue(string(metadata)) + } else { + planData.Metadata = jsontypes.NewNormalizedNull() + } + + diags.Append(state.Set(ctx, &planData)...) + return diags +} + +func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + diags := r.update(ctx, req.Plan, &resp.State) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} diff --git a/internal/elasticsearch/security/user_shared.go b/internal/elasticsearch/security/user_shared.go new file mode 100644 index 000000000..c29e64be3 --- /dev/null +++ b/internal/elasticsearch/security/user_shared.go @@ -0,0 +1,61 @@ +package security + +import ( + "context" + "encoding/json" + + "github.com/elastic/terraform-provider-elasticstack/internal/clients" + "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// resourceSecurityUserRead reads user data and sets it in the schema.ResourceData +// This function is shared between the data source and can be used for compatibility. +func resourceSecurityUserRead(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 + } + usernameId := compId.ResourceId + + user, diags := elasticsearch.GetUser(ctx, client, usernameId) + if user == nil && diags == nil { + d.SetId("") + return diags + } + if diags.HasError() { + return diags + } + + metadata, err := json.Marshal(user.Metadata) + if err != nil { + return diag.FromErr(err) + } + + // set the fields + if err := d.Set("username", usernameId); err != nil { + return diag.FromErr(err) + } + if err := d.Set("email", user.Email); err != nil { + return diag.FromErr(err) + } + if err := d.Set("full_name", user.FullName); err != nil { + return diag.FromErr(err) + } + if err := d.Set("roles", user.Roles); err != nil { + return diag.FromErr(err) + } + if err := d.Set("metadata", string(metadata)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("enabled", user.Enabled); err != nil { + return diag.FromErr(err) + } + + return diags +} diff --git a/provider/plugin_framework.go b/provider/plugin_framework.go index 14a0d9939..e44fcd4da 100644 --- a/provider/plugin_framework.go +++ b/provider/plugin_framework.go @@ -17,6 +17,7 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/api_key" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/role_mapping" "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/system_user" + "github.com/elastic/terraform-provider-elasticstack/internal/elasticsearch/security/user" "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" @@ -120,6 +121,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource { output.NewResource, server_host.NewResource, system_user.NewSystemUserResource, + user.NewUserResource, script.NewScriptResource, maintenance_window.NewResource, enrich.NewEnrichPolicyResource, diff --git a/provider/provider.go b/provider/provider.go index d0a177b56..96f30ce39 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -89,7 +89,6 @@ func New(version string) *schema.Provider { "elasticstack_elasticsearch_ingest_pipeline": ingest.ResourceIngestPipeline(), "elasticstack_elasticsearch_logstash_pipeline": logstash.ResourceLogstashPipeline(), "elasticstack_elasticsearch_security_role": security.ResourceRole(), - "elasticstack_elasticsearch_security_user": security.ResourceUser(), "elasticstack_elasticsearch_snapshot_lifecycle": cluster.ResourceSlm(), "elasticstack_elasticsearch_snapshot_repository": cluster.ResourceSnapshotRepository(), "elasticstack_elasticsearch_transform": transform.ResourceTransform(), From 833144c7f1416971992d11e99a7bc4a18d80a5d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:40:02 +0000 Subject: [PATCH 3/8] Add write-only password attributes (password_wo and password_wo_version) Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- docs/resources/elasticsearch_security_user.md | 53 +++++- .../elasticsearch/security/user/acc_test.go | 48 +++++ .../elasticsearch/security/user/models.go | 2 + internal/elasticsearch/security/user/read.go | 2 +- .../elasticsearch/security/user/schema.go | 21 ++- .../elasticsearch/security/user/update.go | 16 +- .../elasticsearch/security/user/validators.go | 174 ++++++++++++++++++ 7 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 internal/elasticsearch/security/user/validators.go diff --git a/docs/resources/elasticsearch_security_user.md b/docs/resources/elasticsearch_security_user.md index 199b5310f..d571c42ca 100644 --- a/docs/resources/elasticsearch_security_user.md +++ b/docs/resources/elasticsearch_security_user.md @@ -3,12 +3,49 @@ page_title: "elasticstack_elasticsearch_security_user Resource - terraform-provider-elasticstack" subcategory: "Security" description: |- - Adds and updates users in the native realm. These users are commonly referred to as native users. See the security user API documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html for more details. + subcategory: "Elasticsearch" + layout: "" + page_title: "Elasticstack: elasticstack_elasticsearch_security_user Resource" + description: |- + Adds and updates users in the native realm. These users are commonly referred to as native users. + Resource: elasticstack_elasticsearch_security_user + Adds and updates users in the native realm. These users are commonly referred to as native users. See the Elasticsearch security user API documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html for more details. + Example Usage + + resource "elasticstack_elasticsearch_security_user" "user" { + username = "my_user" + password = "changeme" + roles = ["superuser"] + full_name = "John Doe" + email = "john@example.com" + } --- # elasticstack_elasticsearch_security_user (Resource) -Adds and updates users in the native realm. These users are commonly referred to as native users. See the [security user API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html) for more details. +--- +subcategory: "Elasticsearch" +layout: "" +page_title: "Elasticstack: elasticstack_elasticsearch_security_user Resource" +description: |- + Adds and updates users in the native realm. These users are commonly referred to as native users. +--- + +# Resource: elasticstack_elasticsearch_security_user + +Adds and updates users in the native realm. These users are commonly referred to as native users. See the [Elasticsearch security user API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html) for more details. + +## Example Usage + +```terraform +resource "elasticstack_elasticsearch_security_user" "user" { + username = "my_user" + password = "changeme" + roles = ["superuser"] + full_name = "John Doe" + email = "john@example.com" +} +``` ## Example Usage @@ -58,18 +95,20 @@ resource "elasticstack_elasticsearch_security_user" "dev" { ### Required -- `roles` (Set of String) A set of roles the user has. The roles determine the user’s access permissions. Default is []. -- `username` (String) An identifier for the user see the [security API put user documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html#security-api-put-user-path-params) for more details. +- `roles` (Set of String) A set of roles the user has. The roles determine the user's access permissions. +- `username` (String) An identifier for the user (see the [security API put user documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html#security-api-put-user-path-params) for more details). ### 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)) - `email` (String) The email of the user. - `enabled` (Boolean) Specifies whether the user is enabled. The default value is true. - `full_name` (String) The full name of the user. - `metadata` (String) Arbitrary metadata that you want to associate with the user. -- `password` (String, Sensitive) The user’s password. Passwords must be at least 6 characters long. -- `password_hash` (String, Sensitive) A hash of the user’s password. This must be produced using the same hashing algorithm as has been configured for password storage (see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#hashing-settings). +- `password` (String, Sensitive) The user's password. Passwords must be at least 6 characters long. Note: Consider using `password_wo` for better security with ephemeral resources. +- `password_hash` (String, Sensitive) A hash of the user's password. This must be produced using the same hashing algorithm as has been configured for password storage (see the [security settings documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#hashing-settings)). +- `password_wo` (String, Sensitive) Write-only password attribute for use with ephemeral resources. Passwords must be at least 6 characters long. This attribute is designed for use with ephemeral resources like `vault_kv_secret_v2` to prevent secrets from being stored in the Terraform state. Must be used with `password_wo_version`. +- `password_wo_version` (String) Version identifier for the write-only password. This field is used to trigger updates when the password changes. Required when `password_wo` is set. Typically, you would use a hash of the password or a version identifier from your secret management system. ### Read-Only diff --git a/internal/elasticsearch/security/user/acc_test.go b/internal/elasticsearch/security/user/acc_test.go index 437725659..74fcd6af5 100644 --- a/internal/elasticsearch/security/user/acc_test.go +++ b/internal/elasticsearch/security/user/acc_test.go @@ -237,6 +237,54 @@ resource "elasticstack_elasticsearch_security_user" "test" { `, username, role) } +func TestAccResourceSecurityUserWithPasswordWo(t *testing.T) { + username := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + password1 := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + password2 := sdkacctest.RandStringFromCharSet(10, sdkacctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + CheckDestroy: checkResourceSecurityUserDestroy, + ProtoV6ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecurityUserWithPasswordWo(username, password1, "v1"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_user.test", "username", username), + resource.TestCheckTypeSetElemAttr("elasticstack_elasticsearch_security_user.test", "roles.*", "kibana_user"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_user.test", "password_wo_version", "v1"), + resource.TestCheckNoResourceAttr("elasticstack_elasticsearch_security_user.test", "password"), + checks.CheckUserCanAuthenticate(username, password1), + ), + }, + { + Config: testAccResourceSecurityUserWithPasswordWo(username, password2, "v2"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_user.test", "username", username), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_user.test", "password_wo_version", "v2"), + checks.CheckUserCanAuthenticate(username, password2), + ), + }, + }, + }) +} + +func testAccResourceSecurityUserWithPasswordWo(username, password, version string) string { + return fmt.Sprintf(` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_user" "test" { + username = "%s" + roles = ["kibana_user"] + full_name = "Test User" + password_wo = "%s" + password_wo_version = "%s" +} + `, username, password, version) +} + func checkResourceSecurityUserDestroy(s *terraform.State) error { client, err := clients.NewAcceptanceTestingClient() if err != nil { diff --git a/internal/elasticsearch/security/user/models.go b/internal/elasticsearch/security/user/models.go index 83c4fdd5c..fed34b071 100644 --- a/internal/elasticsearch/security/user/models.go +++ b/internal/elasticsearch/security/user/models.go @@ -11,6 +11,8 @@ type UserData struct { Username types.String `tfsdk:"username"` Password types.String `tfsdk:"password"` PasswordHash types.String `tfsdk:"password_hash"` + PasswordWo types.String `tfsdk:"password_wo"` + PasswordWoVersion types.String `tfsdk:"password_wo_version"` FullName types.String `tfsdk:"full_name"` Email types.String `tfsdk:"email"` Roles types.Set `tfsdk:"roles"` diff --git a/internal/elasticsearch/security/user/read.go b/internal/elasticsearch/security/user/read.go index a4ac1c147..ad3beed5c 100644 --- a/internal/elasticsearch/security/user/read.go +++ b/internal/elasticsearch/security/user/read.go @@ -53,7 +53,7 @@ func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp data.Enabled = types.BoolValue(user.Enabled) // Handle metadata - if user.Metadata != nil && len(user.Metadata) > 0 { + if len(user.Metadata) > 0 { metadata, err := json.Marshal(user.Metadata) if err != nil { resp.Diagnostics.AddError("Failed to marshal metadata", err.Error()) diff --git a/internal/elasticsearch/security/user/schema.go b/internal/elasticsearch/security/user/schema.go index 47bc51b19..040d3fccd 100644 --- a/internal/elasticsearch/security/user/schema.go +++ b/internal/elasticsearch/security/user/schema.go @@ -50,12 +50,14 @@ func GetSchema() schema.Schema { }, }, "password": schema.StringAttribute{ - MarkdownDescription: "The user's password. Passwords must be at least 6 characters long.", + MarkdownDescription: "The user's password. Passwords must be at least 6 characters long. Note: Consider using `password_wo` for better security with ephemeral resources.", Optional: true, Sensitive: true, Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), stringvalidator.ConflictsWith(path.MatchRoot("password_hash")), + AtMostOneOf(path.MatchRoot("password_wo")), + PreferWriteOnlyAttribute("password_wo"), }, }, "password_hash": schema.StringAttribute{ @@ -65,6 +67,23 @@ func GetSchema() schema.Schema { Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), stringvalidator.ConflictsWith(path.MatchRoot("password")), + AtMostOneOf(path.MatchRoot("password_wo")), + }, + }, + "password_wo": schema.StringAttribute{ + MarkdownDescription: "Write-only password attribute for use with ephemeral resources. Passwords must be at least 6 characters long. This attribute is designed for use with ephemeral resources like `vault_kv_secret_v2` to prevent secrets from being stored in the Terraform state. Must be used with `password_wo_version`.", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(6, 128), + AtMostOneOf(path.MatchRoot("password"), path.MatchRoot("password_hash")), + }, + }, + "password_wo_version": schema.StringAttribute{ + MarkdownDescription: "Version identifier for the write-only password. This field is used to trigger updates when the password changes. Required when `password_wo` is set. Typically, you would use a hash of the password or a version identifier from your secret management system.", + Optional: true, + Validators: []validator.String{ + RequiresAttribute(path.MatchRoot("password_wo")), }, }, "full_name": schema.StringAttribute{ diff --git a/internal/elasticsearch/security/user/update.go b/internal/elasticsearch/security/user/update.go index ff984f921..483ea2a42 100644 --- a/internal/elasticsearch/security/user/update.go +++ b/internal/elasticsearch/security/user/update.go @@ -54,12 +54,16 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk var user models.User user.Username = usernameId - // Only set password if it's in the plan AND (it's a create OR it has changed from state) - if utils.IsKnown(planData.Password) && (!hasState || !planData.Password.Equal(stateData.Password)) { + // Handle password fields - only set password if it's in the plan AND (it's a create OR it has changed from state) + // Priority: password_wo > password > password_hash + if utils.IsKnown(planData.PasswordWo) && (!hasState || !planData.PasswordWo.Equal(stateData.PasswordWo) || !planData.PasswordWoVersion.Equal(stateData.PasswordWoVersion)) { + // Use write-only password + password := planData.PasswordWo.ValueString() + user.Password = &password + } else if utils.IsKnown(planData.Password) && (!hasState || !planData.Password.Equal(stateData.Password)) { password := planData.Password.ValueString() user.Password = &password - } - if utils.IsKnown(planData.PasswordHash) && (!hasState || !planData.PasswordHash.Equal(stateData.PasswordHash)) { + } else if utils.IsKnown(planData.PasswordHash) && (!hasState || !planData.PasswordHash.Equal(stateData.PasswordHash)) { passwordHash := planData.PasswordHash.ValueString() user.PasswordHash = &passwordHash } @@ -102,9 +106,9 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk } planData.Id = types.StringValue(id.String()) - + // Set computed fields from the API response - if readUser.Metadata != nil && len(readUser.Metadata) > 0 { + if len(readUser.Metadata) > 0 { metadata, err := json.Marshal(readUser.Metadata) if err != nil { diags.AddError("Failed to marshal metadata", err.Error()) diff --git a/internal/elasticsearch/security/user/validators.go b/internal/elasticsearch/security/user/validators.go new file mode 100644 index 000000000..ac1b5daab --- /dev/null +++ b/internal/elasticsearch/security/user/validators.go @@ -0,0 +1,174 @@ +package user + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var ( + _ validator.String = atMostOneOfValidator{} + _ validator.String = requiresAttributeValidator{} + _ validator.String = preferWriteOnlyAttributeValidator{} +) + +// atMostOneOfValidator validates that at most one of the specified attributes is set. +type atMostOneOfValidator struct { + pathExpressions path.Expressions +} + +// AtMostOneOf returns a validator which ensures that at most one of the specified attributes is configured. +func AtMostOneOf(pathExpressions ...path.Expression) validator.String { + return atMostOneOfValidator{ + pathExpressions: pathExpressions, + } +} + +func (v atMostOneOfValidator) Description(_ context.Context) string { + return fmt.Sprintf("Ensure that at most one of these attributes is configured: %v", v.pathExpressions) +} + +func (v atMostOneOfValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v atMostOneOfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + // If the current attribute is null or unknown, no validation required + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + // Count how many of the specified attributes are set + expressions := req.PathExpression.MergeExpressions(v.pathExpressions...) + + for _, expression := range v.pathExpressions { + matchedPaths, diags := req.Config.PathMatches(ctx, expression) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + for _, matchedPath := range matchedPaths { + // Don't compare with self + if matchedPath.Equal(req.Path) { + continue + } + + var matchedValue attr.Value + diags := req.Config.GetAttribute(ctx, matchedPath, &matchedValue) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // If another attribute is also set, that's a conflict + if !matchedValue.IsNull() && !matchedValue.IsUnknown() { + resp.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + req.Path, + fmt.Sprintf("Only one of %s can be configured at a time", expressions), + )) + return + } + } + } +} + +// requiresAttributeValidator validates that if the current attribute is set, the required attribute must also be set. +type requiresAttributeValidator struct { + requiredPath path.Expression +} + +// RequiresAttribute returns a validator which ensures that if the current attribute is configured, the required attribute must also be configured. +func RequiresAttribute(requiredPath path.Expression) validator.String { + return requiresAttributeValidator{ + requiredPath: requiredPath, + } +} + +func (v requiresAttributeValidator) Description(_ context.Context) string { + return fmt.Sprintf("Ensure that if configured, %s must also be configured", v.requiredPath) +} + +func (v requiresAttributeValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v requiresAttributeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + // If the current attribute is null or unknown, no validation required + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + // Check if the required attribute is set + matchedPaths, diags := req.Config.PathMatches(ctx, v.requiredPath) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if len(matchedPaths) == 0 { + resp.Diagnostics.AddAttributeError( + req.Path, + "Missing Required Attribute", + fmt.Sprintf("Attribute %s requires %s to also be set", req.Path, v.requiredPath), + ) + return + } + + for _, matchedPath := range matchedPaths { + var requiredValue attr.Value + diags := req.Config.GetAttribute(ctx, matchedPath, &requiredValue) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if requiredValue.IsNull() || requiredValue.IsUnknown() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Missing Required Attribute", + fmt.Sprintf("Attribute %s requires %s to also be set", req.Path, v.requiredPath), + ) + return + } + } +} + +// preferWriteOnlyAttributeValidator is a validator that suggests using the write-only version of an attribute +type preferWriteOnlyAttributeValidator struct { + writeOnlyAttrName string +} + +// PreferWriteOnlyAttribute returns a validator that warns when a non-write-only attribute is used instead of its write-only counterpart +func PreferWriteOnlyAttribute(writeOnlyAttrName string) validator.String { + return preferWriteOnlyAttributeValidator{ + writeOnlyAttrName: writeOnlyAttrName, + } +} + +func (v preferWriteOnlyAttributeValidator) Description(_ context.Context) string { + return fmt.Sprintf("Suggest using %s for better security with ephemeral resources", v.writeOnlyAttrName) +} + +func (v preferWriteOnlyAttributeValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v preferWriteOnlyAttributeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + // This is just a suggestion/warning, not an error + // If the attribute is set, we add an informational diagnostic + if !req.ConfigValue.IsNull() && !req.ConfigValue.IsUnknown() { + resp.Diagnostics.AddAttributeWarning( + req.Path, + "Consider Using Write-Only Attribute", + fmt.Sprintf("Consider using the '%s' attribute instead when working with ephemeral resources like Vault secrets. "+ + "This prevents the sensitive value from being stored in the state file. "+ + "See https://developer.hashicorp.com/terraform/language/manage-sensitive-data/ephemeral for more information.", + v.writeOnlyAttrName), + ) + } +} From 83ef13171d442da4b32fecfec9d8949630f60fa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 03:44:00 +0000 Subject: [PATCH 4/8] Address code review feedback: simplify validators and fix unused variable Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- internal/elasticsearch/security/user/schema.go | 6 ++---- internal/elasticsearch/security/user/validators.go | 5 +---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/internal/elasticsearch/security/user/schema.go b/internal/elasticsearch/security/user/schema.go index 040d3fccd..7bd4c63bc 100644 --- a/internal/elasticsearch/security/user/schema.go +++ b/internal/elasticsearch/security/user/schema.go @@ -55,8 +55,7 @@ func GetSchema() schema.Schema { Sensitive: true, Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), - stringvalidator.ConflictsWith(path.MatchRoot("password_hash")), - AtMostOneOf(path.MatchRoot("password_wo")), + AtMostOneOf(path.MatchRoot("password_hash"), path.MatchRoot("password_wo")), PreferWriteOnlyAttribute("password_wo"), }, }, @@ -66,8 +65,7 @@ func GetSchema() schema.Schema { Sensitive: true, Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), - stringvalidator.ConflictsWith(path.MatchRoot("password")), - AtMostOneOf(path.MatchRoot("password_wo")), + AtMostOneOf(path.MatchRoot("password"), path.MatchRoot("password_wo")), }, }, "password_wo": schema.StringAttribute{ diff --git a/internal/elasticsearch/security/user/validators.go b/internal/elasticsearch/security/user/validators.go index ac1b5daab..3b7512805 100644 --- a/internal/elasticsearch/security/user/validators.go +++ b/internal/elasticsearch/security/user/validators.go @@ -42,9 +42,6 @@ func (v atMostOneOfValidator) ValidateString(ctx context.Context, req validator. return } - // Count how many of the specified attributes are set - expressions := req.PathExpression.MergeExpressions(v.pathExpressions...) - for _, expression := range v.pathExpressions { matchedPaths, diags := req.Config.PathMatches(ctx, expression) resp.Diagnostics.Append(diags...) @@ -69,7 +66,7 @@ func (v atMostOneOfValidator) ValidateString(ctx context.Context, req validator. if !matchedValue.IsNull() && !matchedValue.IsUnknown() { resp.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( req.Path, - fmt.Sprintf("Only one of %s can be configured at a time", expressions), + fmt.Sprintf("Attribute %s conflicts with %s", req.Path, matchedPath), )) return } From a3410e3dfb25aa172a5cfdd8848b81a17db0dfa4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 06:17:37 +0000 Subject: [PATCH 5/8] Address PR review feedback: use built-in validators, add type assertions, fix password_wo version logic Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- docs/resources/elasticsearch_security_user.md | 37 ---- internal/clients/elasticsearch/security.go | 43 +++++ .../elasticsearch/security/user/create.go | 6 +- .../elasticsearch/security/user/delete.go | 4 +- .../security/user/resource-description.md | 22 --- .../elasticsearch/security/user/resource.go | 5 + .../elasticsearch/security/user/schema.go | 14 +- .../elasticsearch/security/user/update.go | 19 +- .../elasticsearch/security/user/validators.go | 171 ------------------ 9 files changed, 67 insertions(+), 254 deletions(-) delete mode 100644 internal/elasticsearch/security/user/validators.go diff --git a/docs/resources/elasticsearch_security_user.md b/docs/resources/elasticsearch_security_user.md index d571c42ca..c221d8cf9 100644 --- a/docs/resources/elasticsearch_security_user.md +++ b/docs/resources/elasticsearch_security_user.md @@ -3,52 +3,15 @@ page_title: "elasticstack_elasticsearch_security_user Resource - terraform-provider-elasticstack" subcategory: "Security" description: |- - subcategory: "Elasticsearch" - layout: "" - page_title: "Elasticstack: elasticstack_elasticsearch_security_user Resource" - description: |- - Adds and updates users in the native realm. These users are commonly referred to as native users. - Resource: elasticstack_elasticsearch_security_user Adds and updates users in the native realm. These users are commonly referred to as native users. See the Elasticsearch security user API documentation https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html for more details. - Example Usage - - resource "elasticstack_elasticsearch_security_user" "user" { - username = "my_user" - password = "changeme" - roles = ["superuser"] - full_name = "John Doe" - email = "john@example.com" - } --- # elasticstack_elasticsearch_security_user (Resource) ---- -subcategory: "Elasticsearch" -layout: "" -page_title: "Elasticstack: elasticstack_elasticsearch_security_user Resource" -description: |- - Adds and updates users in the native realm. These users are commonly referred to as native users. ---- - -# Resource: elasticstack_elasticsearch_security_user - Adds and updates users in the native realm. These users are commonly referred to as native users. See the [Elasticsearch security user API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html) for more details. ## Example Usage -```terraform -resource "elasticstack_elasticsearch_security_user" "user" { - username = "my_user" - password = "changeme" - roles = ["superuser"] - full_name = "John Doe" - email = "john@example.com" -} -``` - -## Example Usage - ```terraform provider "elasticstack" { elasticsearch {} diff --git a/internal/clients/elasticsearch/security.go b/internal/clients/elasticsearch/security.go index 083d23f56..1dc1fc8d4 100644 --- a/internal/clients/elasticsearch/security.go +++ b/internal/clients/elasticsearch/security.go @@ -89,6 +89,49 @@ func DeleteUser(ctx context.Context, apiClient *clients.ApiClient, username stri return diags } +func DeleteUserFw(ctx context.Context, apiClient *clients.ApiClient, username string) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics + esClient, err := apiClient.GetESClient() + if err != nil { + diags.AddError("Unable to get Elasticsearch client", err.Error()) + return diags + } + res, err := esClient.Security.DeleteUser(username, esClient.Security.DeleteUser.WithContext(ctx)) + if err != nil { + diags.AddError("Unable to delete user", err.Error()) + return diags + } + defer res.Body.Close() + if fwDiags := diagutil.CheckErrorFromFW(res, "Unable to delete a user"); fwDiags.HasError() { + return fwDiags + } + return diags +} + +func PutUserFw(ctx context.Context, apiClient *clients.ApiClient, user *models.User) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics + userBytes, err := json.Marshal(user) + if err != nil { + diags.AddError("Unable to marshal user", err.Error()) + return diags + } + esClient, err := apiClient.GetESClient() + if err != nil { + diags.AddError("Unable to get Elasticsearch client", err.Error()) + return diags + } + res, err := esClient.Security.PutUser(user.Username, bytes.NewReader(userBytes), esClient.Security.PutUser.WithContext(ctx)) + if err != nil { + diags.AddError("Unable to create or update user", err.Error()) + return diags + } + defer res.Body.Close() + if fwDiags := diagutil.CheckErrorFromFW(res, "Unable to create or update a user"); fwDiags.HasError() { + return fwDiags + } + return diags +} + func EnableUser(ctx context.Context, apiClient *clients.ApiClient, username string) fwdiag.Diagnostics { var diags fwdiag.Diagnostics esClient, err := apiClient.GetESClient() diff --git a/internal/elasticsearch/security/user/create.go b/internal/elasticsearch/security/user/create.go index 748c021c7..022b4fd23 100644 --- a/internal/elasticsearch/security/user/create.go +++ b/internal/elasticsearch/security/user/create.go @@ -7,9 +7,5 @@ import ( ) func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - diags := r.update(ctx, req.Plan, &resp.State) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } + resp.Diagnostics.Append(r.update(ctx, req.Plan, &resp.State)...) } diff --git a/internal/elasticsearch/security/user/delete.go b/internal/elasticsearch/security/user/delete.go index de4a3b85b..469564f6d 100644 --- a/internal/elasticsearch/security/user/delete.go +++ b/internal/elasticsearch/security/user/delete.go @@ -5,7 +5,6 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/clients" "github.com/elastic/terraform-provider-elasticstack/internal/clients/elasticsearch" - "github.com/elastic/terraform-provider-elasticstack/internal/diagutil" "github.com/hashicorp/terraform-plugin-framework/resource" ) @@ -28,6 +27,5 @@ func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, r return } - sdkDiags := elasticsearch.DeleteUser(ctx, client, compId.ResourceId) - resp.Diagnostics.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + resp.Diagnostics.Append(elasticsearch.DeleteUserFw(ctx, client, compId.ResourceId)...) } diff --git a/internal/elasticsearch/security/user/resource-description.md b/internal/elasticsearch/security/user/resource-description.md index b7e72ba28..4302a4b16 100644 --- a/internal/elasticsearch/security/user/resource-description.md +++ b/internal/elasticsearch/security/user/resource-description.md @@ -1,23 +1 @@ ---- -subcategory: "Elasticsearch" -layout: "" -page_title: "Elasticstack: elasticstack_elasticsearch_security_user Resource" -description: |- - Adds and updates users in the native realm. These users are commonly referred to as native users. ---- - -# Resource: elasticstack_elasticsearch_security_user - Adds and updates users in the native realm. These users are commonly referred to as native users. See the [Elasticsearch security user API documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html) for more details. - -## Example Usage - -```terraform -resource "elasticstack_elasticsearch_security_user" "user" { - username = "my_user" - password = "changeme" - roles = ["superuser"] - full_name = "John Doe" - email = "john@example.com" -} -``` diff --git a/internal/elasticsearch/security/user/resource.go b/internal/elasticsearch/security/user/resource.go index c67758837..7910a4b4c 100644 --- a/internal/elasticsearch/security/user/resource.go +++ b/internal/elasticsearch/security/user/resource.go @@ -8,6 +8,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" ) +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &userResource{} +var _ resource.ResourceWithConfigure = &userResource{} +var _ resource.ResourceWithImportState = &userResource{} + func NewUserResource() resource.Resource { return &userResource{} } diff --git a/internal/elasticsearch/security/user/schema.go b/internal/elasticsearch/security/user/schema.go index 7bd4c63bc..f926c9f92 100644 --- a/internal/elasticsearch/security/user/schema.go +++ b/internal/elasticsearch/security/user/schema.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -55,8 +56,8 @@ func GetSchema() schema.Schema { Sensitive: true, Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), - AtMostOneOf(path.MatchRoot("password_hash"), path.MatchRoot("password_wo")), - PreferWriteOnlyAttribute("password_wo"), + stringvalidator.ConflictsWith(path.MatchRoot("password_hash"), path.MatchRoot("password_wo")), + stringvalidator.PreferWriteOnlyAttribute(path.MatchRoot("password_wo")), }, }, "password_hash": schema.StringAttribute{ @@ -65,7 +66,7 @@ func GetSchema() schema.Schema { Sensitive: true, Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), - AtMostOneOf(path.MatchRoot("password"), path.MatchRoot("password_wo")), + stringvalidator.ConflictsWith(path.MatchRoot("password"), path.MatchRoot("password_wo")), }, }, "password_wo": schema.StringAttribute{ @@ -74,14 +75,14 @@ func GetSchema() schema.Schema { Sensitive: true, Validators: []validator.String{ stringvalidator.LengthBetween(6, 128), - AtMostOneOf(path.MatchRoot("password"), path.MatchRoot("password_hash")), + stringvalidator.ConflictsWith(path.MatchRoot("password"), path.MatchRoot("password_hash")), }, }, "password_wo_version": schema.StringAttribute{ MarkdownDescription: "Version identifier for the write-only password. This field is used to trigger updates when the password changes. Required when `password_wo` is set. Typically, you would use a hash of the password or a version identifier from your secret management system.", Optional: true, Validators: []validator.String{ - RequiresAttribute(path.MatchRoot("password_wo")), + stringvalidator.AlsoRequires(path.MatchRoot("password_wo")), }, }, "full_name": schema.StringAttribute{ @@ -100,6 +101,9 @@ func GetSchema() schema.Schema { MarkdownDescription: "A set of roles the user has. The roles determine the user's access permissions.", Required: true, ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, }, "metadata": schema.StringAttribute{ MarkdownDescription: "Arbitrary metadata that you want to associate with the user.", diff --git a/internal/elasticsearch/security/user/update.go b/internal/elasticsearch/security/user/update.go index 483ea2a42..7f1527b71 100644 --- a/internal/elasticsearch/security/user/update.go +++ b/internal/elasticsearch/security/user/update.go @@ -28,13 +28,11 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk // Check if we have existing state (this is an update, not a create) hasState := false var stateData UserData - if state != nil { - if getRaw := state.Raw; !getRaw.IsNull() { - hasState = true - diags.Append(state.Get(ctx, &stateData)...) - if diags.HasError() { - return diags - } + if state != nil && !state.Raw.IsNull() { + hasState = true + diags.Append(state.Get(ctx, &stateData)...) + if diags.HasError() { + return diags } } @@ -56,8 +54,8 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk // Handle password fields - only set password if it's in the plan AND (it's a create OR it has changed from state) // Priority: password_wo > password > password_hash - if utils.IsKnown(planData.PasswordWo) && (!hasState || !planData.PasswordWo.Equal(stateData.PasswordWo) || !planData.PasswordWoVersion.Equal(stateData.PasswordWoVersion)) { - // Use write-only password + if utils.IsKnown(planData.PasswordWo) && (!hasState || !planData.PasswordWoVersion.Equal(stateData.PasswordWoVersion)) { + // Use write-only password - changes triggered by version change password := planData.PasswordWo.ValueString() user.Password = &password } else if utils.IsKnown(planData.Password) && (!hasState || !planData.Password.Equal(stateData.Password)) { @@ -92,8 +90,7 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk user.Metadata = metadata } - sdkDiags = elasticsearch.PutUser(ctx, client, &user) - diags.Append(diagutil.FrameworkDiagsFromSDK(sdkDiags)...) + diags.Append(elasticsearch.PutUserFw(ctx, client, &user)...) if diags.HasError() { return diags } diff --git a/internal/elasticsearch/security/user/validators.go b/internal/elasticsearch/security/user/validators.go deleted file mode 100644 index 3b7512805..000000000 --- a/internal/elasticsearch/security/user/validators.go +++ /dev/null @@ -1,171 +0,0 @@ -package user - -import ( - "context" - "fmt" - - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" -) - -var ( - _ validator.String = atMostOneOfValidator{} - _ validator.String = requiresAttributeValidator{} - _ validator.String = preferWriteOnlyAttributeValidator{} -) - -// atMostOneOfValidator validates that at most one of the specified attributes is set. -type atMostOneOfValidator struct { - pathExpressions path.Expressions -} - -// AtMostOneOf returns a validator which ensures that at most one of the specified attributes is configured. -func AtMostOneOf(pathExpressions ...path.Expression) validator.String { - return atMostOneOfValidator{ - pathExpressions: pathExpressions, - } -} - -func (v atMostOneOfValidator) Description(_ context.Context) string { - return fmt.Sprintf("Ensure that at most one of these attributes is configured: %v", v.pathExpressions) -} - -func (v atMostOneOfValidator) MarkdownDescription(ctx context.Context) string { - return v.Description(ctx) -} - -func (v atMostOneOfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - // If the current attribute is null or unknown, no validation required - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - - for _, expression := range v.pathExpressions { - matchedPaths, diags := req.Config.PathMatches(ctx, expression) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - for _, matchedPath := range matchedPaths { - // Don't compare with self - if matchedPath.Equal(req.Path) { - continue - } - - var matchedValue attr.Value - diags := req.Config.GetAttribute(ctx, matchedPath, &matchedValue) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - // If another attribute is also set, that's a conflict - if !matchedValue.IsNull() && !matchedValue.IsUnknown() { - resp.Diagnostics.Append(validatordiag.InvalidAttributeCombinationDiagnostic( - req.Path, - fmt.Sprintf("Attribute %s conflicts with %s", req.Path, matchedPath), - )) - return - } - } - } -} - -// requiresAttributeValidator validates that if the current attribute is set, the required attribute must also be set. -type requiresAttributeValidator struct { - requiredPath path.Expression -} - -// RequiresAttribute returns a validator which ensures that if the current attribute is configured, the required attribute must also be configured. -func RequiresAttribute(requiredPath path.Expression) validator.String { - return requiresAttributeValidator{ - requiredPath: requiredPath, - } -} - -func (v requiresAttributeValidator) Description(_ context.Context) string { - return fmt.Sprintf("Ensure that if configured, %s must also be configured", v.requiredPath) -} - -func (v requiresAttributeValidator) MarkdownDescription(ctx context.Context) string { - return v.Description(ctx) -} - -func (v requiresAttributeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - // If the current attribute is null or unknown, no validation required - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - - // Check if the required attribute is set - matchedPaths, diags := req.Config.PathMatches(ctx, v.requiredPath) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - if len(matchedPaths) == 0 { - resp.Diagnostics.AddAttributeError( - req.Path, - "Missing Required Attribute", - fmt.Sprintf("Attribute %s requires %s to also be set", req.Path, v.requiredPath), - ) - return - } - - for _, matchedPath := range matchedPaths { - var requiredValue attr.Value - diags := req.Config.GetAttribute(ctx, matchedPath, &requiredValue) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - if requiredValue.IsNull() || requiredValue.IsUnknown() { - resp.Diagnostics.AddAttributeError( - req.Path, - "Missing Required Attribute", - fmt.Sprintf("Attribute %s requires %s to also be set", req.Path, v.requiredPath), - ) - return - } - } -} - -// preferWriteOnlyAttributeValidator is a validator that suggests using the write-only version of an attribute -type preferWriteOnlyAttributeValidator struct { - writeOnlyAttrName string -} - -// PreferWriteOnlyAttribute returns a validator that warns when a non-write-only attribute is used instead of its write-only counterpart -func PreferWriteOnlyAttribute(writeOnlyAttrName string) validator.String { - return preferWriteOnlyAttributeValidator{ - writeOnlyAttrName: writeOnlyAttrName, - } -} - -func (v preferWriteOnlyAttributeValidator) Description(_ context.Context) string { - return fmt.Sprintf("Suggest using %s for better security with ephemeral resources", v.writeOnlyAttrName) -} - -func (v preferWriteOnlyAttributeValidator) MarkdownDescription(ctx context.Context) string { - return v.Description(ctx) -} - -func (v preferWriteOnlyAttributeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - // This is just a suggestion/warning, not an error - // If the attribute is set, we add an informational diagnostic - if !req.ConfigValue.IsNull() && !req.ConfigValue.IsUnknown() { - resp.Diagnostics.AddAttributeWarning( - req.Path, - "Consider Using Write-Only Attribute", - fmt.Sprintf("Consider using the '%s' attribute instead when working with ephemeral resources like Vault secrets. "+ - "This prevents the sensitive value from being stored in the state file. "+ - "See https://developer.hashicorp.com/terraform/language/manage-sensitive-data/ephemeral for more information.", - v.writeOnlyAttrName), - ) - } -} From 4832692ae32f77a5d2a703241fc0adfe36e92aca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 06:19:30 +0000 Subject: [PATCH 6/8] Update PutUser and DeleteUser to return Framework diagnostics directly Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- internal/clients/elasticsearch/security.go | 60 ++++--------------- .../elasticsearch/security/user/delete.go | 2 +- .../elasticsearch/security/user/update.go | 2 +- 3 files changed, 13 insertions(+), 51 deletions(-) diff --git a/internal/clients/elasticsearch/security.go b/internal/clients/elasticsearch/security.go index 1dc1fc8d4..6aaa376a8 100644 --- a/internal/clients/elasticsearch/security.go +++ b/internal/clients/elasticsearch/security.go @@ -14,23 +14,26 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" ) -func PutUser(ctx context.Context, apiClient *clients.ApiClient, user *models.User) diag.Diagnostics { - var diags diag.Diagnostics +func PutUser(ctx context.Context, apiClient *clients.ApiClient, user *models.User) fwdiag.Diagnostics { + var diags fwdiag.Diagnostics userBytes, err := json.Marshal(user) if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to marshal user", err.Error()) + return diags } esClient, err := apiClient.GetESClient() if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to get Elasticsearch client", err.Error()) + return diags } res, err := esClient.Security.PutUser(user.Username, bytes.NewReader(userBytes), esClient.Security.PutUser.WithContext(ctx)) if err != nil { - return diag.FromErr(err) + diags.AddError("Unable to create or update user", err.Error()) + return diags } defer res.Body.Close() - if diags := diagutil.CheckError(res, "Unable to create or update a user"); diags.HasError() { - return diags + if fwDiags := diagutil.CheckErrorFromFW(res, "Unable to create or update a user"); fwDiags.HasError() { + return fwDiags } return diags } @@ -72,24 +75,7 @@ func GetUser(ctx context.Context, apiClient *clients.ApiClient, username string) return nil, diags } -func DeleteUser(ctx context.Context, apiClient *clients.ApiClient, username string) diag.Diagnostics { - var diags diag.Diagnostics - esClient, err := apiClient.GetESClient() - if err != nil { - return diag.FromErr(err) - } - res, err := esClient.Security.DeleteUser(username, esClient.Security.DeleteUser.WithContext(ctx)) - if err != nil { - return diag.FromErr(err) - } - defer res.Body.Close() - if diags := diagutil.CheckError(res, "Unable to delete a user"); diags.HasError() { - return diags - } - return diags -} - -func DeleteUserFw(ctx context.Context, apiClient *clients.ApiClient, username string) fwdiag.Diagnostics { +func DeleteUser(ctx context.Context, apiClient *clients.ApiClient, username string) fwdiag.Diagnostics { var diags fwdiag.Diagnostics esClient, err := apiClient.GetESClient() if err != nil { @@ -108,30 +94,6 @@ func DeleteUserFw(ctx context.Context, apiClient *clients.ApiClient, username st return diags } -func PutUserFw(ctx context.Context, apiClient *clients.ApiClient, user *models.User) fwdiag.Diagnostics { - var diags fwdiag.Diagnostics - userBytes, err := json.Marshal(user) - if err != nil { - diags.AddError("Unable to marshal user", err.Error()) - return diags - } - esClient, err := apiClient.GetESClient() - if err != nil { - diags.AddError("Unable to get Elasticsearch client", err.Error()) - return diags - } - res, err := esClient.Security.PutUser(user.Username, bytes.NewReader(userBytes), esClient.Security.PutUser.WithContext(ctx)) - if err != nil { - diags.AddError("Unable to create or update user", err.Error()) - return diags - } - defer res.Body.Close() - if fwDiags := diagutil.CheckErrorFromFW(res, "Unable to create or update a user"); fwDiags.HasError() { - return fwDiags - } - return diags -} - func EnableUser(ctx context.Context, apiClient *clients.ApiClient, username string) fwdiag.Diagnostics { var diags fwdiag.Diagnostics esClient, err := apiClient.GetESClient() diff --git a/internal/elasticsearch/security/user/delete.go b/internal/elasticsearch/security/user/delete.go index 469564f6d..ec626bb63 100644 --- a/internal/elasticsearch/security/user/delete.go +++ b/internal/elasticsearch/security/user/delete.go @@ -27,5 +27,5 @@ func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, r return } - resp.Diagnostics.Append(elasticsearch.DeleteUserFw(ctx, client, compId.ResourceId)...) + resp.Diagnostics.Append(elasticsearch.DeleteUser(ctx, client, compId.ResourceId)...) } diff --git a/internal/elasticsearch/security/user/update.go b/internal/elasticsearch/security/user/update.go index 7f1527b71..58abfbf73 100644 --- a/internal/elasticsearch/security/user/update.go +++ b/internal/elasticsearch/security/user/update.go @@ -90,7 +90,7 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk user.Metadata = metadata } - diags.Append(elasticsearch.PutUserFw(ctx, client, &user)...) + diags.Append(elasticsearch.PutUser(ctx, client, &user)...) if diags.HasError() { return diags } From 25b0fb7bd924606786ad348647253c92b22d3b7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 07:11:25 +0000 Subject: [PATCH 7/8] Read password_wo from config instead of plan for write-only attributes Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- .../elasticsearch/security/user/create.go | 2 +- .../elasticsearch/security/user/update.go | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/elasticsearch/security/user/create.go b/internal/elasticsearch/security/user/create.go index 022b4fd23..7359cd88c 100644 --- a/internal/elasticsearch/security/user/create.go +++ b/internal/elasticsearch/security/user/create.go @@ -7,5 +7,5 @@ import ( ) func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - resp.Diagnostics.Append(r.update(ctx, req.Plan, &resp.State)...) + resp.Diagnostics.Append(r.update(ctx, req.Plan, req.Config, &resp.State)...) } diff --git a/internal/elasticsearch/security/user/update.go b/internal/elasticsearch/security/user/update.go index 58abfbf73..d6a5fa11a 100644 --- a/internal/elasticsearch/security/user/update.go +++ b/internal/elasticsearch/security/user/update.go @@ -12,12 +12,13 @@ import ( "github.com/elastic/terraform-provider-elasticstack/internal/utils" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "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/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" ) -func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk.State) diag.Diagnostics { +func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, config tfsdk.Config, state *tfsdk.State) diag.Diagnostics { var planData UserData var diags diag.Diagnostics diags.Append(plan.Get(ctx, &planData)...) @@ -54,9 +55,16 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk // Handle password fields - only set password if it's in the plan AND (it's a create OR it has changed from state) // Priority: password_wo > password > password_hash - if utils.IsKnown(planData.PasswordWo) && (!hasState || !planData.PasswordWoVersion.Equal(stateData.PasswordWoVersion)) { + // Read password_wo from config as per Terraform write-only attribute guidelines + var passwordWoFromConfig types.String + diags.Append(config.GetAttribute(ctx, path.Root("password_wo"), &passwordWoFromConfig)...) + if diags.HasError() { + return diags + } + + if utils.IsKnown(passwordWoFromConfig) && (!hasState || !planData.PasswordWoVersion.Equal(stateData.PasswordWoVersion)) { // Use write-only password - changes triggered by version change - password := planData.PasswordWo.ValueString() + password := passwordWoFromConfig.ValueString() user.Password = &password } else if utils.IsKnown(planData.Password) && (!hasState || !planData.Password.Equal(stateData.Password)) { password := planData.Password.ValueString() @@ -121,9 +129,5 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, state *tfsdk } func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - diags := r.update(ctx, req.Plan, &resp.State) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } + resp.Diagnostics.Append(r.update(ctx, req.Plan, req.Config, &resp.State)...) } From 599ce146b0ef7968fd6b2d93ac7fe2eb72a5cbb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:42:26 +0000 Subject: [PATCH 8/8] Fix Go formatting (remove trailing whitespace) Co-authored-by: tobio <444668+tobio@users.noreply.github.com> --- internal/elasticsearch/security/user/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/elasticsearch/security/user/update.go b/internal/elasticsearch/security/user/update.go index d6a5fa11a..62b5b371a 100644 --- a/internal/elasticsearch/security/user/update.go +++ b/internal/elasticsearch/security/user/update.go @@ -61,7 +61,7 @@ func (r *userResource) update(ctx context.Context, plan tfsdk.Plan, config tfsdk if diags.HasError() { return diags } - + if utils.IsKnown(passwordWoFromConfig) && (!hasState || !planData.PasswordWoVersion.Equal(stateData.PasswordWoVersion)) { // Use write-only password - changes triggered by version change password := passwordWoFromConfig.ValueString()