Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add system-user resource #188

Merged
merged 10 commits into from
Nov 21, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions docs/resources/elasticsearch_security_system_user.md
Original file line number Diff line number Diff line change
@@ -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 generated by tfplugindocs -->
## 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

<a id="nestedblock--elasticsearch_connection"></a>
### 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.
Original file line number Diff line number Diff line change
@@ -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"
}
}
47 changes: 47 additions & 0 deletions internal/clients/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
161 changes: 161 additions & 0 deletions internal/elasticsearch/security/system_user.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions internal/elasticsearch/security/system_user_test.go
Original file line number Diff line number Diff line change
@@ -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"
}
`
Loading