diff --git a/CHANGELOG.md b/CHANGELOG.md index dc615e5be..5aad8eae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Add `elasticstack_elasticsearch_security_role_mapping` data source ([#178](https://github.com/elastic/terraform-provider-elasticstack/pull/178)) - Apply `total_shards_per_node` setting in `allocate` action in ILM. Supported from Elasticsearch version **7.16** ([#112](https://github.com/elastic/terraform-provider-elasticstack/issues/112)) - Add `elasticstack_elasticsearch_security_api_key` resource ([#193](https://github.com/elastic/terraform-provider-elasticstack/pull/193)) +- Add `elasticstack_elasticsearch_security_system_user` resource to manage built-in user ([#188](https://github.com/elastic/terraform-provider-elasticstack/pull/188)) - Add `unassigned_node_left_delayed_timeout` to index resource ([#196](https://github.com/elastic/terraform-provider-elasticstack/pull/196)) ### Fixed diff --git a/docs/resources/elasticsearch_security_system_user.md b/docs/resources/elasticsearch_security_system_user.md new file mode 100644 index 000000000..6e57ad466 --- /dev/null +++ b/docs/resources/elasticsearch_security_system_user.md @@ -0,0 +1,64 @@ +--- +subcategory: "Security" +layout: "" +page_title: "Elasticstack: elasticstack_elasticsearch_security_system_user Resource" +description: |- + Updates system user's password and enablement. +--- + +# Resource: elasticstack_elasticsearch_security_system_user + +Updates system user's password and enablement. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html +Since this resource is to manage built-in users, destroy will not delete the underlying Elasticsearch and will only remove it from Terraform state. + +## Example Usage + +```terraform +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_system_user" "kibana_system" { + username = "kibana_system" + + // For details on how to generate the hashed password see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html#security-api-put-user-request-body + password_hash = "$2a$10$rMZe6TdsUwBX/TA8vRDz0OLwKAZeCzXM4jT3tfCjpSTB8HoFuq8xO" + + elasticsearch_connection { + endpoints = ["http://localhost:9200"] + username = "elastic" + password = "changeme" + } +} +``` + + +## Schema + +### Required + +- `username` (String) An identifier for the system user (see https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html). + +### Optional + +- `elasticsearch_connection` (Block List, Max: 1) Used to establish connection to Elasticsearch server. Overrides environment variables if present. (see [below for nested schema](#nestedblock--elasticsearch_connection)) +- `enabled` (Boolean) Specifies whether the user is enabled. The default value is true. +- `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). + +### Read-Only + +- `id` (String) Internal identifier of the resource + + +### Nested Schema for `elasticsearch_connection` + +Optional: + +- `api_key` (String, Sensitive) API Key to use for authentication to Elasticsearch +- `ca_data` (String) PEM-encoded custom Certificate Authority certificate +- `ca_file` (String) Path to a custom Certificate Authority certificate +- `endpoints` (List of String, Sensitive) A list of endpoints the Terraform provider will point to. They must include the http(s) schema and port number. +- `insecure` (Boolean) Disable TLS certificate validation +- `password` (String, Sensitive) A password to use for API authentication to Elasticsearch. +- `username` (String) A username to use for API authentication to Elasticsearch. diff --git a/examples/resources/elasticstack_elasticsearch_security_system_user/resource.tf b/examples/resources/elasticstack_elasticsearch_security_system_user/resource.tf new file mode 100644 index 000000000..e3578a117 --- /dev/null +++ b/examples/resources/elasticstack_elasticsearch_security_system_user/resource.tf @@ -0,0 +1,16 @@ +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_system_user" "kibana_system" { + username = "kibana_system" + + // For details on how to generate the hashed password see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-user.html#security-api-put-user-request-body + password_hash = "$2a$10$rMZe6TdsUwBX/TA8vRDz0OLwKAZeCzXM4jT3tfCjpSTB8HoFuq8xO" + + elasticsearch_connection { + endpoints = ["http://localhost:9200"] + username = "elastic" + password = "changeme" + } +} diff --git a/internal/clients/security.go b/internal/clients/security.go index 081a40699..bafa42ecd 100644 --- a/internal/clients/security.go +++ b/internal/clients/security.go @@ -75,6 +75,53 @@ func (a *ApiClient) DeleteElasticsearchUser(ctx context.Context, username string return diags } +func (a *ApiClient) EnableElasticsearchUser(ctx context.Context, username string) diag.Diagnostics { + var diags diag.Diagnostics + res, err := a.es.Security.EnableUser(username, a.es.Security.EnableUser.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + defer res.Body.Close() + if diags := utils.CheckError(res, "Unable to enable system user"); diags.HasError() { + return diags + } + return diags +} + +func (a *ApiClient) DisableElasticsearchUser(ctx context.Context, username string) diag.Diagnostics { + var diags diag.Diagnostics + res, err := a.es.Security.DisableUser(username, a.es.Security.DisableUser.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + defer res.Body.Close() + if diags := utils.CheckError(res, "Unable to disable system user"); diags.HasError() { + return diags + } + return diags +} + +func (a *ApiClient) ChangeElasticsearchUserPassword(ctx context.Context, username string, userPassword *models.UserPassword) diag.Diagnostics { + var diags diag.Diagnostics + userPasswordBytes, err := json.Marshal(userPassword) + if err != nil { + return diag.FromErr(err) + } + res, err := a.es.Security.ChangePassword( + bytes.NewReader(userPasswordBytes), + a.es.Security.ChangePassword.WithUsername(username), + a.es.Security.ChangePassword.WithContext(ctx), + ) + if err != nil { + return diag.FromErr(err) + } + defer res.Body.Close() + if diags := utils.CheckError(res, "Unable to change user's password"); diags.HasError() { + return diags + } + return diags +} + func (a *ApiClient) PutElasticsearchRole(ctx context.Context, role *models.Role) diag.Diagnostics { var diags diag.Diagnostics diff --git a/internal/elasticsearch/security/system_user.go b/internal/elasticsearch/security/system_user.go new file mode 100644 index 000000000..29514c883 --- /dev/null +++ b/internal/elasticsearch/security/system_user.go @@ -0,0 +1,161 @@ +package security + +import ( + "context" + "fmt" + "regexp" + + "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/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 ResourceSystemUser() *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 system user (see https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html).", + 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"}, + }, + "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: "Updates system user's password and enablement. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html", + + CreateContext: resourceSecuritySystemUserPut, + UpdateContext: resourceSecuritySystemUserPut, + ReadContext: resourceSecuritySystemUserRead, + DeleteContext: resourceSecuritySystemUserDelete, + + Schema: userSchema, + } +} + +func resourceSecuritySystemUserPut(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client, err := clients.NewApiClient(d, meta) + if err != nil { + return diag.FromErr(err) + } + usernameId := d.Get("username").(string) + id, diags := client.ID(ctx, usernameId) + if diags.HasError() { + return diags + } + + user, diags := client.GetElasticsearchUser(ctx, usernameId) + if diags.HasError() { + return diags + } + if user == nil || !user.IsSystemUser() { + return diag.Errorf(`System user "%s" not found`, usernameId) + } + + var userPassword models.UserPassword + if v, ok := d.GetOk("password"); ok && d.HasChange("password") { + password := v.(string) + userPassword.Password = &password + } + if v, ok := d.GetOk("password_hash"); ok && d.HasChange("password_hash") { + pass_hash := v.(string) + userPassword.PasswordHash = &pass_hash + } + if userPassword.Password != nil || userPassword.PasswordHash != nil { + if diags := client.ChangeElasticsearchUserPassword(ctx, usernameId, &userPassword); diags.HasError() { + return diags + } + } + + if d.HasChange("enabled") { + if d.Get("enabled").(bool) { + if diags := client.EnableElasticsearchUser(ctx, usernameId); diags.HasError() { + return diags + } + } else { + if diags := client.DisableElasticsearchUser(ctx, usernameId); diags.HasError() { + return diags + } + } + } + + d.SetId(id.String()) + return resourceSecuritySystemUserRead(ctx, d, meta) +} + +func resourceSecuritySystemUserRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + client, err := clients.NewApiClient(d, meta) + if err != nil { + return diag.FromErr(err) + } + compId, diags := clients.CompositeIdFromStr(d.Id()) + if diags.HasError() { + return diags + } + usernameId := compId.ResourceId + + user, diags := client.GetElasticsearchUser(ctx, usernameId) + if diags == nil && (user == nil || !user.IsSystemUser()) { + tflog.Warn(ctx, fmt.Sprintf(`System user "%s" not found, removing from state`, compId.ResourceId)) + d.SetId("") + return diags + } + if diags.HasError() { + return diags + } + + if err := d.Set("username", usernameId); err != nil { + return diag.FromErr(err) + } + if err := d.Set("enabled", user.Enabled); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceSecuritySystemUserDelete(ctx context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + compId, diags := clients.CompositeIdFromStr(d.Id()) + if diags.HasError() { + return diags + } + tflog.Warn(ctx, fmt.Sprintf(`System user '%s' is not deletable, just removing from state`, compId.ResourceId)) + return nil +} diff --git a/internal/elasticsearch/security/system_user_test.go b/internal/elasticsearch/security/system_user_test.go new file mode 100644 index 000000000..de5e06e75 --- /dev/null +++ b/internal/elasticsearch/security/system_user_test.go @@ -0,0 +1,77 @@ +package security_test + +import ( + "regexp" + "testing" + + "github.com/elastic/terraform-provider-elasticstack/internal/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccResourceSecuritySystemUser(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecuritySystemUserCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_system_user.kibana_system", "username", "kibana_system"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_system_user.kibana_system", "enabled", "true"), + ), + }, + { + Config: testAccResourceSecuritySystemUserUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_system_user.kibana_system", "username", "kibana_system"), + resource.TestCheckResourceAttr("elasticstack_elasticsearch_security_system_user.kibana_system", "enabled", "false"), + ), + }, + }, + }) +} + +func TestAccResourceSecuritySystemUserNotFound(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.Providers, + Steps: []resource.TestStep{ + { + Config: testAccResourceSecuritySystemUserNotFound, + ExpectError: regexp.MustCompile(`System user "not_system_user" not found`), + }, + }, + }) +} + +const testAccResourceSecuritySystemUserCreate = ` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_system_user" "kibana_system" { + username = "kibana_system" + password = "new_password" +} + ` +const testAccResourceSecuritySystemUserUpdate = ` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_system_user" "kibana_system" { + username = "kibana_system" + password = "new_password" + enabled = false +} + ` +const testAccResourceSecuritySystemUserNotFound = ` +provider "elasticstack" { + elasticsearch {} +} + +resource "elasticstack_elasticsearch_security_system_user" "test" { + username = "not_system_user" + password = "new_password" +} + ` diff --git a/internal/models/models.go b/internal/models/models.go index df9b7617e..12149be6c 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -11,6 +11,19 @@ type User struct { Enabled bool `json:"enabled"` } +func (u *User) IsSystemUser() bool { + if reserved := u.Metadata["_reserved"]; reserved != nil { + isReserved, ok := reserved.(bool) + return ok && isReserved + } + return false +} + +type UserPassword struct { + Password *string `json:"password,omitempty"` + PasswordHash *string `json:"password_hash,omitempty"` +} + type Role struct { Name string `json:"-"` Applications []Application `json:"applications,omitempty"` diff --git a/provider/provider.go b/provider/provider.go index 6c3d1617c..f77d25fa4 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -136,6 +136,7 @@ func New(version string) func() *schema.Provider { "elasticstack_elasticsearch_security_role": security.ResourceRole(), "elasticstack_elasticsearch_security_role_mapping": security.ResourceRoleMapping(), "elasticstack_elasticsearch_security_user": security.ResourceUser(), + "elasticstack_elasticsearch_security_system_user": security.ResourceSystemUser(), "elasticstack_elasticsearch_snapshot_lifecycle": cluster.ResourceSlm(), "elasticstack_elasticsearch_snapshot_repository": cluster.ResourceSnapshotRepository(), "elasticstack_elasticsearch_script": cluster.ResourceScript(), diff --git a/templates/resources/elasticsearch_security_system_user.md.tmpl b/templates/resources/elasticsearch_security_system_user.md.tmpl new file mode 100644 index 000000000..ca2fa49b9 --- /dev/null +++ b/templates/resources/elasticsearch_security_system_user.md.tmpl @@ -0,0 +1,18 @@ +--- +subcategory: "Security" +layout: "" +page_title: "Elasticstack: elasticstack_elasticsearch_security_system_user Resource" +description: |- + Updates system user's password and enablement. +--- + +# Resource: elasticstack_elasticsearch_security_system_user + +Updates system user's password and enablement. See, https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html +Since this resource is to manage built-in users, destroy will not delete the underlying Elasticsearch and will only remove it from Terraform state. + +## Example Usage + +{{ tffile "examples/resources/elasticstack_elasticsearch_security_system_user/resource.tf" }} + +{{ .SchemaMarkdown | trimspace }}