diff --git a/README.md b/README.md index a0324de..46b6d37 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,21 @@ after the configured lease ends, in 5 minutes. We can use `vault lease [renew|revoke]` to manually alter the length of the lease, up to the configured maximum time. +For clustered stacks, we create ephemeral credentials for specific nodes: + + $ vault read splunk/creds/local-admin/idx.example.com + Key Value + --- ----- + lease_id splunk/creds/local-admin/idx.example.com/u2N97uUVVDw3YVaETB1yRK74 + lease_duration 30s + lease_renewable true + connection local + password &R1iX5W%$41QGcf^yN2i9%%#tUNf58h! + roles [admin] + url https://idx.example.com:8089 + username vault_29079642-4aa1-1979-f402-b3775f2713a7 + + Rotate the Splunk admin password: vault write -f splunk/rotate-root/local diff --git a/backend_test.go b/backend_test.go index 44304b3..068927c 100644 --- a/backend_test.go +++ b/backend_test.go @@ -87,9 +87,11 @@ func TestBackend_RoleCRUD(t *testing.T) { } testRoleConfig := roleConfig{ - Connection: "testconn", - Roles: []string{"admin"}, - UserPrefix: "my-custom-prefix", + Connection: "testconn", + Roles: []string{"admin"}, + AllowedNodeTypes: []string{"*"}, + PasswordSpec: DefaultPasswordSpec(), + UserPrefix: "my-custom-prefix", } logicaltest.Test(t, logicaltest.TestCase{ diff --git a/go.mod b/go.mod index 7fe3d1f..441516d 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/elazarl/go-bindata-assetfs v1.0.0 // indirect github.com/fatih/structs v1.1.0 github.com/go-sql-driver/mysql v1.4.1 // indirect + github.com/go-test/deep v1.0.5 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/google/go-querystring v1.0.0 github.com/gotestyourself/gotestyourself v2.2.0+incompatible // indirect @@ -48,6 +49,7 @@ require ( github.com/pierrec/lz4 v2.2.6+incompatible // indirect github.com/prometheus/client_golang v1.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sethvargo/go-password v0.1.3 github.com/sirupsen/logrus v1.4.2 // indirect golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 diff --git a/go.sum b/go.sum index 1147c3f..8f5a9b5 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.5 h1:AKODKU3pDH1RzZzm6YZu77YWtEAq6uh1rLIAQlay2qc= +github.com/go-test/deep v1.0.5/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -185,6 +187,8 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sethvargo/go-password v0.1.3 h1:18KkbGDkw8SuzeohAbWqBLNSfRQblVwEHOLbPa0PvWM= +github.com/sethvargo/go-password v0.1.3/go.mod h1:2tyaaoHK/AlXwh5WWQDYjqQbHcq4cjPj5qb/ciYvu/Q= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= diff --git a/password.go b/password.go new file mode 100644 index 0000000..aa39c33 --- /dev/null +++ b/password.go @@ -0,0 +1,40 @@ +package splunk + +import ( + "github.com/sethvargo/go-password/password" +) + +type PasswordSpec struct { + Length int `json:"length" structs:"length"` + NumDigits int `json:"num_digits" structs:"num_digits"` + NumSymbols int `json:"num_symbols" structs:"num_symbols"` + AllowUpper bool `json:"allow_upper" structs:"allow_upper"` + AllowRepeat bool `json:"allow_repeat" structs:"allow_repeat"` +} + +func DefaultPasswordSpec() *PasswordSpec { + return &PasswordSpec{ + Length: 32, + NumDigits: 4, + NumSymbols: 4, + AllowUpper: true, + AllowRepeat: true, + } +} + +func GeneratePassword(spec *PasswordSpec) (string, error) { + passwdgen, err := password.NewGenerator(&password.GeneratorInput{ + LowerLetters: password.LowerLetters, + UpperLetters: password.UpperLetters, + Digits: password.Digits, + Symbols: "_&^%$#@!", // mostly shell-safe set, TE-101 + }) + if err != nil { + return "", err + } + + if spec == nil { + spec = DefaultPasswordSpec() + } + return passwdgen.Generate(spec.Length, spec.NumDigits, spec.NumSymbols, !spec.AllowUpper, spec.AllowRepeat) +} diff --git a/path_creds_create.go b/path_creds_create.go index d5f0e73..354fb1a 100644 --- a/path_creds_create.go +++ b/path_creds_create.go @@ -3,19 +3,15 @@ package splunk import ( "context" "fmt" + "github.com/hashicorp/errwrap" - "github.com/hashicorp/go-uuid" + uuid "github.com/hashicorp/go-uuid" "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" "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"), @@ -84,7 +80,7 @@ func (b *backend) credsReadHandlerStandalone(ctx context.Context, req *logical.R } // Generate credentials - userUUID, err := uuid.GenerateUUID() + userUUID, err := generateUserID(role) if err != nil { return nil, err } @@ -93,7 +89,7 @@ func (b *backend) credsReadHandlerStandalone(ctx context.Context, req *logical.R userPrefix = fmt.Sprintf("%s_%s", role.UserPrefix, req.DisplayName) } username := fmt.Sprintf("%s_%s", userPrefix, userUUID) - passwd, err := uuid.GenerateUUID() + passwd, err := generateUserPassword(role) if err != nil { return nil, errwrap.Wrapf("error generating new password {{err}}", err) } @@ -128,20 +124,23 @@ func (b *backend) credsReadHandlerStandalone(ctx context.Context, req *logical.R return resp, nil } -func findNode(nodeFQDN string, hosts []splunk.ServerInfoEntry) (bool, error) { +func findNode(nodeFQDN string, hosts []splunk.ServerInfoEntry, roleConfig *roleConfig) (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 + // Return true if the requested node type is allowed + if strutil.StrListContains(roleConfig.AllowedNodeTypes, "*") { + return true, nil + } for _, role := range host.Content.Roles { - if role == SEARCHHEAD { + if strutil.StrListContainsGlob(roleConfig.AllowedNodeTypes, role) { 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 %q does not have an allowed node type", nodeFQDN) } } - return false, fmt.Errorf("host: %s not found", nodeFQDN) + return false, fmt.Errorf("host %q not found", nodeFQDN) } func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -180,7 +179,7 @@ func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Reques b.Logger().Error("Error while reading SearchPeers from cluster master", err) return nil, errwrap.Wrapf("unable to read searchpeers from cluster master: {{err}}", err) } - _, err = findNode(nodeFQDN, nodes) + _, err = findNode(nodeFQDN, nodes, role) if err != nil { return nil, err } @@ -193,7 +192,7 @@ func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Reques return nil, err } // Generate credentials - userUUID, err := uuid.GenerateUUID() + userUUID, err := generateUserID(role) if err != nil { return nil, err } @@ -202,11 +201,10 @@ func (b *backend) credsReadHandlerMulti(ctx context.Context, req *logical.Reques userPrefix = fmt.Sprintf("%s_%s", role.UserPrefix, req.DisplayName) } username := fmt.Sprintf("%s_%s", userPrefix, userUUID) - passwd, err := uuid.GenerateUUID() + passwd, err := generateUserPassword(role) if err != nil { return nil, errwrap.Wrapf("error generating new password: {{err}}", err) } - conn.Params().BaseURL = nodeFQDN opts := splunk.CreateUserOptions{ Name: username, Password: passwd, @@ -251,6 +249,19 @@ func (b *backend) credsReadHandler(ctx context.Context, req *logical.Request, d return b.credsReadHandlerStandalone(ctx, req, d) } +func generateUserID(roleConfig *roleConfig) (string, error) { + return uuid.GenerateUUID() +} + +func generateUserPassword(roleConfig *roleConfig) (string, error) { + passwd, err := GeneratePassword(roleConfig.PasswordSpec) + if err == nil { + return passwd, nil + } + // fallback + return uuid.GenerateUUID() +} + const pathCredsCreateHelpSyn = ` Request Splunk credentials for a certain role. ` diff --git a/path_roles.go b/path_roles.go index 10af065..4b4e117 100644 --- a/path_roles.go +++ b/path_roles.go @@ -35,6 +35,14 @@ func (b *backend) pathRoles() *framework.Path { Type: framework.TypeCommaStringSlice, Description: "Comma-separated string or list of Splunk roles.", }, + "allowed_node_types": &framework.FieldSchema{ + Type: framework.TypeCommaStringSlice, + Description: trimIndent(` + Comma-separated string or array of node type (glob) patterns that are allowed + to fetch credentials for. If empty, no nodes are allowed. If "*", all + node types are allowed.`), + Default: []string{"*"}, + }, "default_app": &framework.FieldSchema{ Type: framework.TypeString, Description: trimIndent(` @@ -114,6 +122,10 @@ func (b *backend) rolesWriteHandler(ctx context.Context, req *logical.Request, d if maxTTLRaw, ok := getValue(data, req.Operation, "max_ttl"); ok { role.MaxTTL = time.Duration(maxTTLRaw.(int)) * time.Second } + if allowed_node_types, ok := getValue(data, req.Operation, "allowed_node_types"); ok { + role.AllowedNodeTypes = allowed_node_types.([]string) + } + role.PasswordSpec = DefaultPasswordSpec() // XXX make configurable if roles, ok := getValue(data, req.Operation, "roles"); ok { role.Roles = roles.([]string) diff --git a/role.go b/role.go index 2eab11c..a86f226 100644 --- a/role.go +++ b/role.go @@ -11,9 +11,11 @@ import ( ) type roleConfig struct { - Connection string `json:"connection" structs:"connection"` - DefaultTTL time.Duration `json:"default_ttl" structs:"default_ttl"` - MaxTTL time.Duration `json:"max_ttl" structs:"max_ttl"` + Connection string `json:"connection" structs:"connection"` + DefaultTTL time.Duration `json:"default_ttl" structs:"default_ttl"` + MaxTTL time.Duration `json:"max_ttl" structs:"max_ttl"` + AllowedNodeTypes []string `json:"allowed_node_types" structs:"allowed_node_types"` + PasswordSpec *PasswordSpec `json:"password_spec" structs:"password_spec"` // Splunk user attributes Roles []string `json:"roles" structs:"roles"`