354 lines
12 KiB
Go
354 lines
12 KiB
Go
package elasticsearch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/errwrap"
|
|
multierror "github.com/hashicorp/go-multierror"
|
|
"github.com/hashicorp/vault/api"
|
|
"github.com/hashicorp/vault/sdk/database/dbplugin"
|
|
"github.com/hashicorp/vault/sdk/database/helper/credsutil"
|
|
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
|
|
)
|
|
|
|
func New() (interface{}, error) {
|
|
db := NewElasticsearch()
|
|
return dbplugin.NewDatabaseErrorSanitizerMiddleware(db, db.SecretValues), nil
|
|
}
|
|
|
|
func Run(apiTLSConfig *api.TLSConfig) error {
|
|
dbplugin.Serve(NewElasticsearch(), api.VaultPluginTLSProvider(apiTLSConfig))
|
|
return nil
|
|
}
|
|
|
|
func NewElasticsearch() *Elasticsearch {
|
|
return &Elasticsearch{
|
|
credentialProducer: &credsutil.SQLCredentialsProducer{
|
|
DisplayNameLen: 15,
|
|
RoleNameLen: 15,
|
|
UsernameLen: 100,
|
|
Separator: "-",
|
|
},
|
|
}
|
|
}
|
|
|
|
// Elasticsearch implements dbplugin's Database interface.
|
|
type Elasticsearch struct {
|
|
|
|
// The CredentialsProducer is never mutated and thus is inherently thread-safe.
|
|
credentialProducer credsutil.CredentialsProducer
|
|
|
|
// This protects the config from races while also allowing multiple threads
|
|
// to read the config simultaneously when it's not changing.
|
|
mux sync.RWMutex
|
|
|
|
// The root credential config.
|
|
config map[string]interface{}
|
|
}
|
|
|
|
func (es *Elasticsearch) Type() (string, error) {
|
|
return "elasticsearch", nil
|
|
}
|
|
|
|
// SecretValues is used by some error-sanitizing middleware in Vault that basically
|
|
// replaces the keys in the map with the values given so they're not leaked via
|
|
// error messages.
|
|
func (es *Elasticsearch) SecretValues() map[string]interface{} {
|
|
es.mux.RLock()
|
|
defer es.mux.RUnlock()
|
|
|
|
replacements := make(map[string]interface{})
|
|
for _, secretKey := range []string{"password", "client_key"} {
|
|
vIfc, found := es.config[secretKey]
|
|
if !found {
|
|
continue
|
|
}
|
|
secretVal, ok := vIfc.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// So, supposing a password of "0pen5e5ame",
|
|
// this will cause that string to get replaced with "[password]".
|
|
replacements[secretVal] = "[" + secretKey + "]"
|
|
}
|
|
return replacements
|
|
}
|
|
|
|
// Init is called on `$ vault write database/config/:db-name`,
|
|
// or when you do a creds call after Vault's been restarted.
|
|
func (es *Elasticsearch) Init(ctx context.Context, config map[string]interface{}, verifyConnection bool) (map[string]interface{}, error) {
|
|
|
|
// Validate the config to provide immediate feedback to the user.
|
|
// Ensure required string fields are provided in the expected format.
|
|
for _, requiredField := range []string{"username", "password", "url"} {
|
|
raw, ok := config[requiredField]
|
|
if !ok {
|
|
return nil, fmt.Errorf(`%q must be provided`, requiredField)
|
|
}
|
|
if _, ok := raw.(string); !ok {
|
|
return nil, fmt.Errorf(`%q must be a string`, requiredField)
|
|
}
|
|
}
|
|
|
|
// Ensure optional string fields are provided in the expected format.
|
|
for _, optionalField := range []string{"ca_cert", "ca_path", "client_cert", "client_key", "tls_server_name"} {
|
|
raw, ok := config[optionalField]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, ok = raw.(string); !ok {
|
|
return nil, fmt.Errorf(`%q must be a string`, optionalField)
|
|
}
|
|
}
|
|
|
|
// Check the one optional bool field is in the expected format.
|
|
if raw, ok := config["insecure"]; ok {
|
|
if _, ok = raw.(bool); !ok {
|
|
return nil, errors.New(`"insecure" must be a bool`)
|
|
}
|
|
}
|
|
|
|
// Test the given config to see if we can make a client.
|
|
client, err := buildClient(config)
|
|
if err != nil {
|
|
return nil, errwrap.Wrapf("couldn't make client with inbound config: {{err}}", err)
|
|
}
|
|
|
|
// Optionally, test the given config to see if we can make a successful call.
|
|
if verifyConnection {
|
|
// Whether this role is found or unfound, if we're configured correctly there will
|
|
// be no err from the call. However, if something is misconfigured, this will yield
|
|
// an error response, which will be described in the returned error.
|
|
if _, err := client.GetRole(ctx, "vault-test"); err != nil {
|
|
return nil, errwrap.Wrapf("client test of getting a role failed: {{err}}", err)
|
|
}
|
|
}
|
|
|
|
// Everything's working, write the new config to memory and storage.
|
|
es.mux.Lock()
|
|
defer es.mux.Unlock()
|
|
es.config = config
|
|
return es.config, nil
|
|
}
|
|
|
|
// CreateUser is called on `$ vault read database/creds/:role-name`
|
|
// and it's the first time anything is touched from `$ vault write database/roles/:role-name`.
|
|
// This is likely to be the highest-throughput method for this plugin.
|
|
func (es *Elasticsearch) CreateUser(ctx context.Context, statements dbplugin.Statements, usernameConfig dbplugin.UsernameConfig, _ time.Time) (string, string, error) {
|
|
username, err := es.credentialProducer.GenerateUsername(usernameConfig)
|
|
if err != nil {
|
|
return "", "", errwrap.Wrapf(fmt.Sprintf("unable to generate username for %q: {{err}}", usernameConfig), err)
|
|
}
|
|
|
|
password, err := es.credentialProducer.GeneratePassword()
|
|
if err != nil {
|
|
return "", "", errwrap.Wrapf("unable to generate password: {{err}}", err)
|
|
}
|
|
|
|
stmt, err := newCreationStatement(statements)
|
|
if err != nil {
|
|
return "", "", errwrap.Wrapf("unable to read creation_statements: {{err}}", err)
|
|
}
|
|
|
|
user := &User{
|
|
Password: password,
|
|
Roles: stmt.PreexistingRoles,
|
|
}
|
|
|
|
// Don't let anyone write the config while we're using it for our current client.
|
|
es.mux.RLock()
|
|
defer es.mux.RUnlock()
|
|
|
|
client, err := buildClient(es.config)
|
|
if err != nil {
|
|
return "", "", errwrap.Wrapf("unable to get client: {{err}}", err)
|
|
}
|
|
|
|
// If the RoleToCreate map has been populated with any data, we have one role to create.
|
|
// There can either be one RoleToCreate and no PreexistingRoles, or >= 1 PreexistingRoles
|
|
// and no RoleToCreate. They're mutually exclusive.
|
|
if len(stmt.RoleToCreate) > 0 {
|
|
// We'll simply name the role the same thing as the username, making it easy to tie back to this user.
|
|
if err := client.CreateRole(ctx, username, stmt.RoleToCreate); err != nil {
|
|
return "", "", errwrap.Wrapf(fmt.Sprintf("unable to create role name %s, role definition %q: {{err}}", username, stmt.RoleToCreate), err)
|
|
}
|
|
user.Roles = []string{username}
|
|
}
|
|
if err := client.CreateUser(ctx, username, user); err != nil {
|
|
return "", "", errwrap.Wrapf(fmt.Sprintf("unable to create user name %s, user %q: {{err}}", username, user), err)
|
|
}
|
|
return username, password, nil
|
|
}
|
|
|
|
// RenewUser gets called on `$ vault lease renew {{lease-id}}`. It automatically pushes out the amount of time until
|
|
// the database secrets engine calls RevokeUser, if appropriate.
|
|
func (es *Elasticsearch) RenewUser(_ context.Context, _ dbplugin.Statements, _ string, _ time.Time) error {
|
|
// Normally, this function would update a "VALID UNTIL" statement on a database user
|
|
// but there's no similar need here.
|
|
return nil
|
|
}
|
|
|
|
// RevokeUser is called when a lease expires.
|
|
func (es *Elasticsearch) RevokeUser(ctx context.Context, statements dbplugin.Statements, username string) error {
|
|
stmt, err := newCreationStatement(statements)
|
|
if err != nil {
|
|
return errwrap.Wrapf("unable to read creation_statements: {{err}}", err)
|
|
}
|
|
|
|
// Don't let anyone write the config while we're using it for our current client.
|
|
es.mux.RLock()
|
|
defer es.mux.RUnlock()
|
|
|
|
client, err := buildClient(es.config)
|
|
if err != nil {
|
|
return errwrap.Wrapf("unable to get client: {{err}}", err)
|
|
}
|
|
|
|
var errs error
|
|
if len(stmt.RoleToCreate) > 0 {
|
|
// If the role already doesn't exist because it was successfully deleted on a previous
|
|
// attempt to run this code, there will be no error, so it's harmless to try.
|
|
if err := client.DeleteRole(ctx, username); err != nil {
|
|
errs = multierror.Append(errs, errwrap.Wrapf(fmt.Sprintf("unable to delete role name %s: {{err}}", username), err))
|
|
}
|
|
}
|
|
// Same with the user. If it was already deleted on a previous attempt, there won't be an
|
|
// error.
|
|
if err := client.DeleteUser(ctx, username); err != nil {
|
|
errs = multierror.Append(errs, errwrap.Wrapf(fmt.Sprintf("unable to create user name %s: {{err}}", username), err))
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// RotateRootCredentials doesn't require any statements from the user because it's not configurable in any
|
|
// way. We simply generate a new password and hit a pre-defined Elasticsearch REST API to rotate them.
|
|
func (es *Elasticsearch) RotateRootCredentials(ctx context.Context, _ []string) (map[string]interface{}, error) {
|
|
newPassword, err := es.credentialProducer.GeneratePassword()
|
|
if err != nil {
|
|
return nil, errwrap.Wrapf("unable to generate root password: {{err}}", err)
|
|
}
|
|
|
|
// Don't let anyone read or write the config while we're in the process of rotating the password.
|
|
es.mux.Lock()
|
|
defer es.mux.Unlock()
|
|
|
|
client, err := buildClient(es.config)
|
|
if err != nil {
|
|
return nil, errwrap.Wrapf("unable to get client: {{err}}", err)
|
|
}
|
|
|
|
if err := client.ChangePassword(ctx, es.config["username"].(string), newPassword); err != nil {
|
|
return nil, errwrap.Wrapf("unable to change password: {{}}", err)
|
|
}
|
|
|
|
es.config["password"] = newPassword
|
|
return es.config, nil
|
|
}
|
|
|
|
func (es *Elasticsearch) Close() error {
|
|
// NOOP, nothing to close.
|
|
return nil
|
|
}
|
|
|
|
// DEPRECATED, included for backward-compatibility until removal
|
|
func (es *Elasticsearch) Initialize(ctx context.Context, config map[string]interface{}, verifyConnection bool) error {
|
|
_, err := es.Init(ctx, config, verifyConnection)
|
|
return err
|
|
}
|
|
|
|
func newCreationStatement(statements dbplugin.Statements) (*creationStatement, error) {
|
|
if len(statements.Creation) == 0 {
|
|
return nil, dbutil.ErrEmptyCreationStatement
|
|
}
|
|
stmt := &creationStatement{}
|
|
if err := json.Unmarshal([]byte(statements.Creation[0]), stmt); err != nil {
|
|
return nil, errwrap.Wrapf(fmt.Sprintf("unable to unmarshal %s: {{err}}", []byte(statements.Creation[0])), err)
|
|
}
|
|
if len(stmt.PreexistingRoles) > 0 && len(stmt.RoleToCreate) > 0 {
|
|
return nil, errors.New(`"elasticsearch_roles" and "elasticsearch_role_definition" are mutually exclusive`)
|
|
}
|
|
return stmt, nil
|
|
}
|
|
|
|
type creationStatement struct {
|
|
PreexistingRoles []string `json:"elasticsearch_roles"`
|
|
RoleToCreate map[string]interface{} `json:"elasticsearch_role_definition"`
|
|
}
|
|
|
|
// buildClient is a helper method for building a client from the present config,
|
|
// which is done often.
|
|
func buildClient(config map[string]interface{}) (*Client, error) {
|
|
|
|
// We can presume these required fields are provided by strings
|
|
// because they're validated in Init.
|
|
clientConfig := &ClientConfig{
|
|
Username: config["username"].(string),
|
|
Password: config["password"].(string),
|
|
BaseURL: config["url"].(string),
|
|
}
|
|
|
|
hasTLSConf := false
|
|
tlsConf := &TLSConfig{}
|
|
|
|
// We can presume that if these are provided, they're in the expected format
|
|
// because they're also validated in Init.
|
|
if raw, ok := config["ca_cert"]; ok {
|
|
tlsConf.CACert = raw.(string)
|
|
hasTLSConf = true
|
|
}
|
|
if raw, ok := config["ca_path"]; ok {
|
|
tlsConf.CAPath = raw.(string)
|
|
hasTLSConf = true
|
|
}
|
|
if raw, ok := config["client_cert"]; ok {
|
|
tlsConf.ClientCert = raw.(string)
|
|
hasTLSConf = true
|
|
}
|
|
if raw, ok := config["client_key"]; ok {
|
|
tlsConf.ClientKey = raw.(string)
|
|
hasTLSConf = true
|
|
}
|
|
if raw, ok := config["tls_server_name"]; ok {
|
|
tlsConf.TLSServerName = raw.(string)
|
|
hasTLSConf = true
|
|
}
|
|
if raw, ok := config["insecure"]; ok {
|
|
tlsConf.Insecure = raw.(bool)
|
|
hasTLSConf = true
|
|
}
|
|
|
|
// We should only fulfill the clientConfig's TLSConfig pointer if we actually
|
|
// want the client to use TLS.
|
|
if hasTLSConf {
|
|
clientConfig.TLSConfig = tlsConf
|
|
}
|
|
|
|
client, err := NewClient(clientConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// GenerateCredentials returns a generated password
|
|
func (es *Elasticsearch) GenerateCredentials(ctx context.Context) (string, error) {
|
|
password, err := es.credentialProducer.GeneratePassword()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return password, nil
|
|
}
|
|
|
|
// SetCredentials is used to set the credentials for a database user to a
|
|
// specific username and password. This is not currently supported by the
|
|
// elastic search plugin, but is needed to conform to the dbplugin.Database
|
|
// interface
|
|
func (es *Elasticsearch) SetCredentials(ctx context.Context, statements dbplugin.Statements, staticConfig dbplugin.StaticUserConfig) (username string, password string, err error) {
|
|
return "", "", dbutil.Unimplemented()
|
|
}
|