From 3d8f24b26116fd0877540aa2da26e942905df795 Mon Sep 17 00:00:00 2001 From: Amey Bhide Date: Thu, 21 Nov 2019 11:49:36 -0800 Subject: [PATCH 1/4] Add GetSearchPeers() method This will be used for creating ephemeral users for multi-node Splunk deployments --- clients/splunk/deployment.go | 19 +++++++++++++++++++ clients/splunk/properties.go | 6 +++--- clients/splunk/properties_test.go | 3 +-- clients/splunk/splunk.go | 2 ++ conn.go | 1 + path_config_connection.go | 5 +++++ 6 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 clients/splunk/deployment.go diff --git a/clients/splunk/deployment.go b/clients/splunk/deployment.go new file mode 100644 index 0000000..be02742 --- /dev/null +++ b/clients/splunk/deployment.go @@ -0,0 +1,19 @@ +package splunk + +// DeploymentService encapsulates the Deployment portion of the Splunk API +type DeploymentService struct { + client *Client +} + +func newDeploymentService(client *Client) *DeploymentService { + return &DeploymentService{ + client: client, + } +} + +// GetSearchPeers returns information about all search peers +func (d *DeploymentService) GetSearchPeers() ([]ServerInfoEntry, *Response, error) { + info := make([]ServerInfoEntry, 0) + resp, err := Receive(d.client.New().Get("search/distributed/peers"), &info) + return info, resp, err +} diff --git a/clients/splunk/properties.go b/clients/splunk/properties.go index f5fd49f..095f415 100644 --- a/clients/splunk/properties.go +++ b/clients/splunk/properties.go @@ -29,10 +29,10 @@ type Entry struct { // stringResponseDecoder decodes http response string // Properties API operates on particular key in the configuration file. // CRUD for properties API returns JSON/XML encoded response for error cases and returns a string response for success -type stringResponseDecoder struct{ +type stringResponseDecoder struct { } -func getPropertiesUri(file string, stanza string, key string) (string) { +func getPropertiesUri(file string, stanza string, key string) string { return fmt.Sprintf("properties/%s/%s/%s", url.PathEscape(file), url.PathEscape(stanza), url.PathEscape(key)) } @@ -74,4 +74,4 @@ func (p *PropertiesService) GetKey(file string, stanza string, key string) (*str return nil, resp, relevantError(err, apiError) } return &output.Value, resp, relevantError(err, apiError) -} \ No newline at end of file +} diff --git a/clients/splunk/properties_test.go b/clients/splunk/properties_test.go index 35d3db7..65bd5c7 100644 --- a/clients/splunk/properties_test.go +++ b/clients/splunk/properties_test.go @@ -12,7 +12,7 @@ func TestPropertiesService_GetKey(t *testing.T) { _, response, err := propertiesSvc.GetKey("foo", "bar", "key") assert.ErrorContains(t, err, "splunk: foo does not exist") assert.Equal(t, response.StatusCode, 404) - _, response, err = propertiesSvc.GetKey("b/a/z","b-ar", "k-ey") + _, response, err = propertiesSvc.GetKey("b/a/z", "b-ar", "k-ey") assert.ErrorContains(t, err, "ERROR splunk: Directory traversal risk in /nobody/system/b/a/z at segment \"b/a/z\"") assert.Equal(t, response.StatusCode, 403) _, response, err = propertiesSvc.GetKey("foo-bar", "b/a/z", "k-ey") @@ -22,7 +22,6 @@ func TestPropertiesService_GetKey(t *testing.T) { assert.ErrorContains(t, err, "splunk: bar does not exist") assert.Equal(t, response.StatusCode, 404) - _, response, _ = propertiesSvc.GetKey("server", "general", "pass4SymmKey") assert.Equal(t, response.StatusCode, 200) diff --git a/clients/splunk/splunk.go b/clients/splunk/splunk.go index 7e4aff5..b347493 100644 --- a/clients/splunk/splunk.go +++ b/clients/splunk/splunk.go @@ -20,6 +20,7 @@ type API struct { Introspection *IntrospectionService AccessControl *AccessControlService Properties *PropertiesService + Deployment *DeploymentService // XXX ... } @@ -39,6 +40,7 @@ func (params *APIParams) NewAPI(ctx context.Context) *API { Introspection: newIntrospectionService(client.New()), AccessControl: newAccessControlService(client.New()), Properties: newPropertiesService(client.New()), + Deployment: newDeploymentService(client.New()), } } diff --git a/conn.go b/conn.go index 76bc22e..1cc8f05 100644 --- a/conn.go +++ b/conn.go @@ -30,6 +30,7 @@ type splunkConfig struct { Username string `json:"username" structs:"username"` Password string `json:"password" structs:"password"` URL string `json:"url" structs:"url"` + IsStandalone bool `json:"is_standalone" structs:"is_standalone"` AllowedRoles []string `json:"allowed_roles" structs:"allowed_roles"` Verify bool `json:"verify" structs:"verify"` InsecureTLS bool `json:"insecure_tls" structs:"insecure_tls"` diff --git a/path_config_connection.go b/path_config_connection.go index d10cd86..c58bdc0 100644 --- a/path_config_connection.go +++ b/path_config_connection.go @@ -33,6 +33,11 @@ func (b *backend) pathConfigConnection() *framework.Path { Type: framework.TypeString, Description: "Splunk server URL.", }, + "is_standalone": &framework.FieldSchema{ + Type: framework.TypeBool, + Description: "Whether this is a standalone or multi-node deployment", + Default: false, + }, "allowed_roles": &framework.FieldSchema{ Type: framework.TypeCommaStringSlice, Description: trimIndent(` From 34079291b4a1a21dabe5b58c7c09c83342a4ffbf Mon Sep 17 00:00:00 2001 From: Amey Bhide Date: Thu, 21 Nov 2019 17:11:27 -0800 Subject: [PATCH 2/4] Add support for creating ephemeral users for multi-node Splunk deployments --- backend.go | 3 +- path_config_connection.go | 4 + path_creds_create.go | 155 +++++++++++++++++++++++++++++++++++++- path_rotate_root.go | 2 +- secret_creds.go | 4 +- 5 files changed, 161 insertions(+), 7 deletions(-) diff --git a/backend.go b/backend.go index 5fa60e4..c3d8eeb 100644 --- a/backend.go +++ b/backend.go @@ -41,6 +41,7 @@ func newBackend() logical.Backend { b.pathRolesList(), b.pathRoles(), b.pathCredsCreate(), + b.pathCredsCreateMulti(), }, Secrets: []*framework.Secret{ b.pathSecretCreds(), @@ -53,7 +54,7 @@ func newBackend() logical.Backend { return &b } -func (b *backend) ensureConnection(ctx context.Context, name string, config *splunkConfig) (*splunk.API, error) { +func (b *backend) ensureConnection(ctx context.Context, config *splunkConfig) (*splunk.API, error) { if conn, ok := b.conn.Load(config.ID); ok { return conn.(*splunk.API), nil } diff --git a/path_config_connection.go b/path_config_connection.go index c58bdc0..aee53be 100644 --- a/path_config_connection.go +++ b/path_config_connection.go @@ -170,6 +170,10 @@ func (b *backend) connectionWriteHandler(ctx context.Context, req *logical.Reque if config.URL == "" { return logical.ErrorResponse("empty URL"), nil } + if isStandalone, ok := getValue(data, req.Operation, "is_standalone"); ok { + config.IsStandalone = isStandalone.(bool) + } + if verifyRaw, ok := getValue(data, req.Operation, "verify"); ok { config.Verify = verifyRaw.(bool) } diff --git a/path_creds_create.go b/path_creds_create.go index e329267..8304de5 100644 --- a/path_creds_create.go +++ b/path_creds_create.go @@ -11,11 +11,15 @@ import ( "github.com/splunk/vault-plugin-splunk/clients/splunk" ) +const ( + SEARCHHEAD = "search_head" + INDEXER = "indexer" +) func (b *backend) pathCredsCreate() *framework.Path { return &framework.Path{ Pattern: "creds/" + framework.GenericNameRegex("name"), Fields: map[string]*framework.FieldSchema{ - "name": &framework.FieldSchema{ + "name": { Type: framework.TypeString, Description: "Name of the role", }, @@ -30,8 +34,119 @@ func (b *backend) pathCredsCreate() *framework.Path { } } -func (b *backend) credsReadHandler(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { +func (b *backend) pathCredsCreateMulti() *framework.Path { + return &framework.Path{ + Pattern: "creds/" + framework.GenericNameRegex("name") + "/" + framework.GenericNameRegex("node_fqdn"), + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: "Name of the role", + }, + "node_fqdn": { + Type: framework.TypeString, + Description: "FQDN for the Splunk Stack node", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.ReadOperation: b.credsReadHandler, + }, + + HelpSynopsis: pathCredsCreateHelpSyn, + HelpDescription: pathCredsCreateHelpDesc, + } +} + +func (b *backend) credsReadHandlerStandalone(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name := d.Get("name").(string) + role, err := roleConfigLoad(ctx, req.Storage, name) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse(fmt.Sprintf("role not found: %q", name)), nil + } + + config, err := connectionConfigLoad(ctx, req.Storage, role.Connection) + if err != nil { + return nil, err + } + + // If role name isn't in allowed roles, send back a permission denied. + if !strutil.StrListContains(config.AllowedRoles, "*") && !strutil.StrListContainsGlob(config.AllowedRoles, name) { + return nil, fmt.Errorf("%q is not an allowed role for connection %q", name, role.Connection) + } + + conn, err := b.ensureConnection(ctx, config) + if err != nil { + return nil, err + } + + // Generate credentials + userUUID, err := uuid.GenerateUUID() + if err != nil { + return nil, err + } + userPrefix := role.UserPrefix + if role.UserPrefix == defaultUserPrefix { + userPrefix = fmt.Sprintf("%s_%s", role.UserPrefix, req.DisplayName) + } + username := fmt.Sprintf("%s_%s", userPrefix, userUUID) + passwd, err := uuid.GenerateUUID() + if err != nil { + return nil, errwrap.Wrapf("error generating new password {{err}}", err) + } + opts := splunk.CreateUserOptions{ + Name: username, + Password: passwd, + Roles: role.Roles, + DefaultApp: role.DefaultApp, + Email: role.Email, + TZ: role.TZ, + } + if _, _, err := conn.AccessControl.Authentication.Users.Create(&opts); err != nil { + return nil, err + } + + resp := b.Secret(secretCredsType).Response(map[string]interface{}{ + // return to user + "username": username, + "password": passwd, + "roles": role.Roles, + "connection": role.Connection, + "url": conn.Params().BaseURL, + }, map[string]interface{}{ + // store (with lease) + "username": username, + "role": name, + "connection": role.Connection, + }) + resp.Secret.TTL = role.DefaultTTL + resp.Secret.MaxTTL = role.MaxTTL + + return resp, nil +} + +func findNode(nodeFQDN string, hosts []splunk.ServerInfoEntry) (bool, error) { + for _, host := range hosts { + // check if node_fqdn is in either of HostFQDN or Host. User might not always the FQDN on the cli input + if host.Content.HostFQDN == nodeFQDN || host.Content.Host == nodeFQDN { + // Return true if the requested node is a search head + for _, role := range host.Content.Roles { + if role == SEARCHHEAD { + return true, nil + } + } + return false, fmt.Errorf("host: %s isn't search head; creating ephemeral creds is only supported for search heads", nodeFQDN) + } + } + return false, fmt.Errorf("host: %s not found", nodeFQDN) +} + +func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { name := d.Get("name").(string) + node, _ := d.GetOk("node_fqdn") + nodeFQDN := node.(string) role, err := roleConfigLoad(ctx, req.Storage, name) if err != nil { return nil, err @@ -44,17 +159,38 @@ func (b *backend) credsReadHandler(ctx context.Context, req *logical.Request, d if err != nil { return nil, err } + // Check if isStandalone is set + if config.IsStandalone { + return nil, fmt.Errorf("expected is_standalone to be set for connection: %q", role.Connection) + } // If role name isn't in allowed roles, send back a permission denied. if !strutil.StrListContains(config.AllowedRoles, "*") && !strutil.StrListContainsGlob(config.AllowedRoles, name) { return nil, fmt.Errorf("%q is not an allowed role for connection %q", name, role.Connection) } - conn, err := b.ensureConnection(ctx, role.Connection, config) + conn, err := b.ensureConnection(ctx, config) + if err != nil { + return nil, err + } + + nodes, _, err := conn.Deployment.GetSearchPeers() + if err != nil { + b.Logger().Error("Error while reading SearchPeers from cluster master", err) + return nil, fmt.Errorf("unable to read searchpeers from cluster master") + } + _, err = findNode(nodeFQDN, nodes) if err != nil { return nil, err } + // Re-create connection for node + config.URL = "https://" + nodeFQDN + ":8089" + config.ID = "" + conn, err = b.ensureConnection(ctx, config) + if err != nil { + return nil, err + } // Generate credentials userUUID, err := uuid.GenerateUUID() if err != nil { @@ -69,6 +205,7 @@ func (b *backend) credsReadHandler(ctx context.Context, req *logical.Request, d if err != nil { return nil, errwrap.Wrapf("error generating new password {{err}}", err) } + conn.Params().BaseURL = nodeFQDN opts := splunk.CreateUserOptions{ Name: username, Password: passwd, @@ -100,6 +237,18 @@ func (b *backend) credsReadHandler(ctx context.Context, req *logical.Request, d return resp, nil } +func (b *backend) credsReadHandler(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name := d.Get("name").(string) + node_fqdn, present := d.GetOk("node_fqdn") + // if node_fqdn is specified then the treat the request for a multi-node deployment + if present { + b.Logger().Debug(fmt.Sprintf("node_fqdn: [%s] specified for role: [%s]. using clustered mode getting temporary creds", node_fqdn.(string), name)) + return b.credsReadHandlerMulti(ctx, req, d) + } + b.Logger().Debug(fmt.Sprintf("node_fqdn not specified for role: [%s]. using standalone mode getting temporary creds", name)) + return b.credsReadHandlerStandalone(ctx, req, d) +} + const pathCredsCreateHelpSyn = ` Request Splunk credentials for a certain role. ` diff --git a/path_rotate_root.go b/path_rotate_root.go index 0606433..b30474c 100644 --- a/path_rotate_root.go +++ b/path_rotate_root.go @@ -34,7 +34,7 @@ func (b *backend) rotateRootUpdateHandler(ctx context.Context, req *logical.Requ if err != nil { return nil, err } - conn, err := b.ensureConnection(ctx, name, oldConfig) + conn, err := b.ensureConnection(ctx, oldConfig) if err != nil { return nil, err } diff --git a/secret_creds.go b/secret_creds.go index 3533247..162d47b 100644 --- a/secret_creds.go +++ b/secret_creds.go @@ -51,7 +51,7 @@ func (b *backend) secretCredsRenewHandler(ctx context.Context, req *logical.Requ if err != nil { return nil, err } - conn, err := b.ensureConnection(ctx, role.Connection, config) + conn, err := b.ensureConnection(ctx, config) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func (b *backend) secretCredsRevokeHandler(ctx context.Context, req *logical.Req if err != nil { return nil, err } - conn, err := b.ensureConnection(ctx, connName, config) + conn, err := b.ensureConnection(ctx, config) if err != nil { return nil, err } From f0886fd177a86a381a9eed4b6de41f114df04f80 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 21 Jan 2020 02:49:53 -0800 Subject: [PATCH 3/4] Fix lease expiration for non-master nodes plumb node name through to lease data. --- path_config_connection.go | 6 +++--- path_creds_create.go | 10 ++++++---- secret_creds.go | 28 ++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/path_config_connection.go b/path_config_connection.go index aee53be..8a40eb5 100644 --- a/path_config_connection.go +++ b/path_config_connection.go @@ -35,7 +35,7 @@ func (b *backend) pathConfigConnection() *framework.Path { }, "is_standalone": &framework.FieldSchema{ Type: framework.TypeBool, - Description: "Whether this is a standalone or multi-node deployment", + Description: `Whether this is a standalone or multi-node deployment. Default: false`, Default: false, }, "allowed_roles": &framework.FieldSchema{ @@ -64,7 +64,7 @@ func (b *backend) pathConfigConnection() *framework.Path { Default: "tls12", Description: trimIndent(` Minimum TLS version to use. Accepted values are "tls10", "tls11" or - "tls12". Defaults to "tls12".`), + "tls12". Default: "tls12".`), }, "pem_bundle": &framework.FieldSchema{ Type: framework.TypeString, @@ -87,7 +87,7 @@ func (b *backend) pathConfigConnection() *framework.Path { "connect_timeout": &framework.FieldSchema{ Type: framework.TypeDurationSecond, Default: "30s", - Description: `The connection timeout to use. Default: 30s.`, + Description: `The connection timeout to use. Default: 30s.`, }, }, diff --git a/path_creds_create.go b/path_creds_create.go index 8304de5..ceb9f2f 100644 --- a/path_creds_create.go +++ b/path_creds_create.go @@ -13,8 +13,9 @@ import ( const ( SEARCHHEAD = "search_head" - INDEXER = "indexer" + INDEXER = "indexer" ) + func (b *backend) pathCredsCreate() *framework.Path { return &framework.Path{ Pattern: "creds/" + framework.GenericNameRegex("name"), @@ -161,7 +162,7 @@ func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Reques } // Check if isStandalone is set if config.IsStandalone { - return nil, fmt.Errorf("expected is_standalone to be set for connection: %q", role.Connection) + return nil, fmt.Errorf("expected is_standalone to be unset for connection: %q", role.Connection) } // If role name isn't in allowed roles, send back a permission denied. @@ -186,8 +187,8 @@ func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Reques // Re-create connection for node config.URL = "https://" + nodeFQDN + ":8089" - config.ID = "" - conn, err = b.ensureConnection(ctx, config) + // XXX config.ID = "" + conn, err = config.newConnection(ctx) // XXX cache if err != nil { return nil, err } @@ -230,6 +231,7 @@ func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Reques "username": username, "role": name, "connection": role.Connection, + "node_fqdn": nodeFQDN, }) resp.Secret.TTL = role.DefaultTTL resp.Secret.MaxTTL = role.MaxTTL diff --git a/secret_creds.go b/secret_creds.go index 162d47b..99668ff 100644 --- a/secret_creds.go +++ b/secret_creds.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" + "github.com/splunk/vault-plugin-splunk/clients/splunk" ) const secretCredsType = "creds" @@ -35,6 +36,12 @@ func (b *backend) secretCredsRenewHandler(ctx context.Context, req *logical.Requ return nil, fmt.Errorf("error during renew: could not find role with name %q", roleName) } + nodeFQDN := "" + nodeFQDNRaw, ok := req.Secret.InternalData["node_fqdn"] + if ok { + nodeFQDN = nodeFQDNRaw.(string) + } + // Make sure we increase the VALID UNTIL endpoint for this user. ttl, _, err := framework.CalculateTTL(b.System(), req.Secret.Increment, role.DefaultTTL, 0, role.MaxTTL, 0, req.Secret.IssueTime) if err != nil { @@ -51,7 +58,7 @@ func (b *backend) secretCredsRenewHandler(ctx context.Context, req *logical.Requ if err != nil { return nil, err } - conn, err := b.ensureConnection(ctx, config) + conn, err := b.ensureNodeConnection(ctx, config, nodeFQDN) if err != nil { return nil, err } @@ -74,6 +81,11 @@ func (b *backend) secretCredsRevokeHandler(ctx context.Context, req *logical.Req if !ok { return nil, fmt.Errorf("unable to convert connection name") } + nodeFQDN := "" + nodeFQDNRaw, ok := req.Secret.InternalData["node_fqdn"] + if ok { + nodeFQDN = nodeFQDNRaw.(string) + } usernameRaw, ok := req.Secret.InternalData["username"] if !ok { return nil, fmt.Errorf("username is missing on the lease") @@ -84,7 +96,7 @@ func (b *backend) secretCredsRevokeHandler(ctx context.Context, req *logical.Req if err != nil { return nil, err } - conn, err := b.ensureConnection(ctx, config) + conn, err := b.ensureNodeConnection(ctx, config, nodeFQDN) if err != nil { return nil, err } @@ -95,3 +107,15 @@ func (b *backend) secretCredsRevokeHandler(ctx context.Context, req *logical.Req } return nil, nil } + +func (b *backend) ensureNodeConnection(ctx context.Context, config *splunkConfig, nodeFQDN string) (*splunk.API, error) { + b.Logger().Debug(fmt.Sprintf("connection for node_fqdn: [%s]", nodeFQDN)) + if nodeFQDN == "" { + return b.ensureConnection(ctx, config) + } + + // we connect to a node, not the cluster master + nodeConfig := *config + nodeConfig.URL = "https://" + nodeFQDN + ":8089" + return nodeConfig.newConnection(ctx) // XXX cache +} From 02fbc2167ed461b3ffe1342fb8cb4b4926677089 Mon Sep 17 00:00:00 2001 From: Michael Weber Date: Tue, 21 Jan 2020 02:51:34 -0800 Subject: [PATCH 4/4] go.mod cleanup --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 49bf367..7fe3d1f 100644 --- a/go.mod +++ b/go.mod @@ -40,8 +40,6 @@ require ( github.com/mitchellh/go-testing-interface v1.0.0 // indirect github.com/mitchellh/mapstructure v1.1.2 github.com/mitchellh/reflectwalk v1.0.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0-rc1 // indirect github.com/opencontainers/image-spec v1.0.1 // indirect github.com/opencontainers/runc v0.1.1 // indirect