From 93682165695bcd3b73d810471bd60c8d5320e1ca Mon Sep 17 00:00:00 2001 From: Anton Kachurin Date: Tue, 12 Apr 2022 13:41:29 +0300 Subject: [PATCH] Implement `username` generation by `template` and `password` generation by `policy` (#60) Implement `username` generation by `template` and `password` generation by `policy` Resolve: #58 Resolve: #59 Reviewed-by: Anton Kachurin Reviewed-by: Rodion Gyrbu Reviewed-by: Anton Sidelnikov --- acceptance/cloud_test.go | 30 ++++++++------ acceptance/creds_test.go | 26 ++++++++---- acceptance/plugin_test.go | 42 ++++++++++++++++---- acceptance/roles_test.go | 15 +++---- docs/api.md | 11 +++++- go.mod | 1 + go.sum | 1 + openstack/backend.go | 27 +++++++++---- openstack/backend_test.go | 10 ++++- openstack/path_cloud.go | 35 +++++++++++++++-- openstack/path_cloud_test.go | 43 ++++++++++++++------ openstack/path_creds.go | 74 +++++++++++++++++++++++------------ openstack/path_creds_test.go | 23 ++++++----- openstack/path_role.go | 6 +-- openstack/path_rotate_root.go | 18 ++------- openstack/random_string.go | 47 +++++++++++++++++++++- 16 files changed, 295 insertions(+), 114 deletions(-) diff --git a/acceptance/cloud_test.go b/acceptance/cloud_test.go index 1e8c84f..6bb1ec9 100644 --- a/acceptance/cloud_test.go +++ b/acceptance/cloud_test.go @@ -15,10 +15,12 @@ import ( ) type cloudData struct { - AuthURL string `json:"auth_url"` - UserDomainName string `json:"user_domain_name"` - Username string `json:"username"` - Password string `json:"password"` + AuthURL string `json:"auth_url"` + UserDomainName string `json:"user_domain_name"` + Username string `json:"username"` + Password string `json:"password"` + UsernameTemplate string `json:"username_template"` + PasswordPolicy string `json:"password_policy"` } func extractCloudData(t *testing.T, resp *http.Response) *cloudData { @@ -36,10 +38,12 @@ func (p *PluginTest) TestCloudLifecycle() { t := p.T() data := map[string]interface{}{ - "auth_url": "https://example.com/v3/", - "username": tools.RandomString("us", 4), - "password": tools.RandomString("", 15), - "user_domain_name": "Default", + "auth_url": "https://example.com/v3/", + "username": tools.RandomString("us", 4), + "password": tools.RandomString("", 15), + "user_domain_name": "Default", + "username_template": "test{{ .RoleName }}{{ .CloudName }}", + "password_policy": tools.RandomString("p", 5), } cloudName := "test-write" @@ -63,10 +67,12 @@ func (p *PluginTest) TestCloudLifecycle() { assert.Equal(t, http.StatusOK, resp.StatusCode) expected := &cloudData{ - AuthURL: data["auth_url"].(string), - UserDomainName: data["user_domain_name"].(string), - Username: data["username"].(string), - Password: data["password"].(string), + AuthURL: data["auth_url"].(string), + UserDomainName: data["user_domain_name"].(string), + Username: data["username"].(string), + Password: data["password"].(string), + UsernameTemplate: data["username_template"].(string), + PasswordPolicy: data["password_policy"].(string), } assert.Equal(t, expected, extractCloudData(t, resp)) }) diff --git a/acceptance/creds_test.go b/acceptance/creds_test.go index 81d3f76..54662e0 100644 --- a/acceptance/creds_test.go +++ b/acceptance/creds_test.go @@ -58,6 +58,16 @@ func (p *PluginTest) TestCredsLifecycle() { for name, data := range cases { t.Run(name, func(t *testing.T) { data := data + + _, err := p.vaultDo( + http.MethodPost, + pluginPwdPolicyEndpoint, + map[string]interface{}{ + "policy": pwdPolicy, + }, + ) + require.NoError(t, err) + roleName := openstack.RandomString(openstack.NameDefaultSet, 4) resp, err := p.vaultDo( @@ -74,7 +84,7 @@ func (p *PluginTest) TestCredsLifecycle() { cloudToRoleMap(data.Root, data.Cloud, data.ProjectID, data.DomainID, data.SecretType, data.UserRoles), ) require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode, readJSONResponse(t, resp)) + assert.Equal(t, http.StatusNoContent, resp.StatusCode, readJSONResponse(t, resp)) resp, err = p.vaultDo( http.MethodGet, @@ -98,7 +108,7 @@ func (p *PluginTest) TestCredsLifecycle() { nil, ) require.NoError(t, err) - assertStatusCode(t, http.StatusOK, resp) + assertStatusCode(t, http.StatusNoContent, resp) resp, err = p.vaultDo( http.MethodDelete, @@ -117,11 +127,13 @@ func credsURL(roleName string) string { func cloudToCloudMap(cloud *openstack.OsCloud) map[string]interface{} { return map[string]interface{}{ - "name": cloud.Name, - "auth_url": cloud.AuthURL, - "username": cloud.Username, - "password": cloud.Password, - "user_domain_name": cloud.UserDomainName, + "name": cloud.Name, + "auth_url": cloud.AuthURL, + "username": cloud.Username, + "password": cloud.Password, + "user_domain_name": cloud.UserDomainName, + "username_template": cloud.UsernameTemplate, + "password_policy": cloud.PasswordPolicy, } } diff --git a/acceptance/plugin_test.go b/acceptance/plugin_test.go index 735dac0..eb3639b 100644 --- a/acceptance/plugin_test.go +++ b/acceptance/plugin_test.go @@ -29,11 +29,37 @@ import ( const ( pluginBin = "vault-plugin-secrets-openstack" pluginAlias = "openstack" + + policyAlias = "openstack-policy" + + pwdPolicy = ` +length=20 + +rule "charset" { + charset = "abcdefghijklmnopqrstuvwxyz" + min-chars = 1 +} + +rule "charset" { + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + min-chars = 1 +} + +rule "charset" { + charset = "0123456789" + min-chars = 1 +} + +rule "charset" { + charset = "!@#$%^&*" + min-chars = 1 +}` ) var ( - pluginCatalogEndpoint = fmt.Sprintf("/v1/sys/plugins/catalog/secret/%s", pluginAlias) - pluginMountEndpoint = fmt.Sprintf("/v1/sys/mounts/%s", pluginAlias) + pluginCatalogEndpoint = fmt.Sprintf("/v1/sys/plugins/catalog/secret/%s", pluginAlias) + pluginMountEndpoint = fmt.Sprintf("/v1/sys/mounts/%s", pluginAlias) + pluginPwdPolicyEndpoint = fmt.Sprintf("/v1/sys/policies/password/%s", policyAlias) cloudBaseEndpoint = fmt.Sprintf("/v1/%s/cloud", pluginAlias) ) @@ -242,11 +268,13 @@ func openstackCloudConfig(t *testing.T) *openstack.OsCloud { require.NoError(t, err) return &openstack.OsCloud{ - Name: cloudName, - AuthURL: cloud.AuthInfo.AuthURL, - UserDomainName: getDomainName(cloud.AuthInfo), - Username: cloud.AuthInfo.Username, - Password: cloud.AuthInfo.Password, + Name: cloudName, + AuthURL: cloud.AuthInfo.AuthURL, + UserDomainName: getDomainName(cloud.AuthInfo), + Username: cloud.AuthInfo.Username, + Password: cloud.AuthInfo.Password, + UsernameTemplate: "vault-{{ .RoleName }}-{{ random 4 }}", + PasswordPolicy: "openstack-policy", } } diff --git a/acceptance/roles_test.go b/acceptance/roles_test.go index 1f9713b..1a275fe 100644 --- a/acceptance/roles_test.go +++ b/acceptance/roles_test.go @@ -43,11 +43,12 @@ func (p *PluginTest) TestRoleLifecycle() { t := p.T() cloud := &openstack.OsCloud{ - Name: openstack.RandomString(openstack.NameDefaultSet, 10), - AuthURL: "https://example.com/v3", - UserDomainName: openstack.RandomString(openstack.NameDefaultSet, 10), - Username: openstack.RandomString(openstack.NameDefaultSet, 10), - Password: openstack.RandomString(openstack.PwdDefaultSet, 10), + Name: openstack.RandomString(openstack.NameDefaultSet, 10), + AuthURL: "https://example.com/v3", + UserDomainName: openstack.RandomString(openstack.NameDefaultSet, 10), + Username: openstack.RandomString(openstack.NameDefaultSet, 10), + Password: openstack.RandomString(openstack.PwdDefaultSet, 10), + UsernameTemplate: "u-{{ .RoleName }}-{{ random 4 }}", } p.makeCloud(cloud) @@ -61,7 +62,7 @@ func (p *PluginTest) TestRoleLifecycle() { data, ) require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode, readJSONResponse(t, resp)) + assert.Equal(t, http.StatusNoContent, resp.StatusCode, readJSONResponse(t, resp)) }) t.Run("ReadRole", func(t *testing.T) { @@ -105,7 +106,7 @@ func (p *PluginTest) TestRoleLifecycle() { nil, ) require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) }) } diff --git a/docs/api.md b/docs/api.md index c234a50..ea821ee 100644 --- a/docs/api.md +++ b/docs/api.md @@ -25,6 +25,14 @@ will overwrite them. * `password` `(string: )` - OpenStack password of the root user. +* `username_template` `(string)` - Template used for usernames of temporary users. For details on templating syntax + please refer to [Username Templating](https://www.vaultproject.io/docs/concepts/username-templating). + Additional fields available for the template are `.CloudName`, `.RoleName`. + +* `password_policy` `(string)` - Specifies a password policy name to use when creating dynamic credentials. + Defaults to generating an alphanumeric password if not set. For details on password policies please refer + to [Password Policies](https://www.vaultproject.io/docs/concepts/password-policies). + ### Sample Payload ```json @@ -32,7 +40,8 @@ will overwrite them. "auth_url": "https://example.com/v3/", "username": "admin", "password": "RcigTiYrJjVmEkrV71Cd", - "user_domain_name": "Default" + "user_domain_name": "Default", + "username_template": "user-{{ .RoleName }}-{{ random 4 }}" } ``` diff --git a/go.mod b/go.mod index 2ebb7c8..110beea 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/hashicorp/go-plugin v1.4.3 // indirect github.com/hashicorp/go-retryablehttp v0.6.6 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/base62 v0.1.1 // indirect github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.1 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.1 // indirect diff --git a/go.sum b/go.sum index 0cc62db..ff7e0d5 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,7 @@ github.com/hashicorp/go-retryablehttp v0.6.6 h1:HJunrbHTDDbBb/ay4kxa1n+dLmttUlnP github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/base62 v0.1.1 h1:6KMBnfEv0/kLAz0O76sliN5mXbCDcLfs2kP7ssP7+DQ= github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 h1:cCRo8gK7oq6A2L6LICkUZ+/a5rLiRXFMf1Qd4xSwxTc= github.com/hashicorp/go-secure-stdlib/mlock v0.1.1/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= diff --git a/openstack/backend.go b/openstack/backend.go index bd401db..04dfafb 100644 --- a/openstack/backend.go +++ b/openstack/backend.go @@ -22,6 +22,8 @@ type sharedCloud struct { client *gophercloud.ServiceClient lock sync.Mutex + + passwords *Passwords } type backend struct { @@ -30,7 +32,7 @@ type backend struct { clouds map[string]*sharedCloud } -func Factory(_ context.Context, _ *logical.BackendConfig) (logical.Backend, error) { +func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { b := new(backend) b.Backend = &framework.Backend{ Help: backendHelp, @@ -54,14 +56,23 @@ func Factory(_ context.Context, _ *logical.BackendConfig) (logical.Backend, erro }, BackendType: logical.TypeLogical, } + + if err := b.Setup(ctx, conf); err != nil { + return nil, err + } + return b, nil } func (b *backend) getSharedCloud(name string) *sharedCloud { + passwords := &Passwords{PolicyGenerator: b.System()} if c, ok := b.clouds[name]; ok { + if c.passwords == nil { + c.passwords = passwords + } return c } - cloud := &sharedCloud{name: name} + cloud := &sharedCloud{name: name, passwords: passwords} if b.clouds == nil { b.clouds = make(map[string]*sharedCloud) } @@ -121,9 +132,11 @@ func (c *sharedCloud) initClient(ctx context.Context, s logical.Storage) error { } type OsCloud struct { - Name string `json:"name"` - AuthURL string `json:"auth_url"` - UserDomainName string `json:"user_domain_name"` - Username string `json:"username"` - Password string `json:"password"` + Name string `json:"name"` + AuthURL string `json:"auth_url"` + UserDomainName string `json:"user_domain_name"` + Username string `json:"username"` + Password string `json:"password"` + UsernameTemplate string `json:"username_template"` + PasswordPolicy string `json:"password_policy"` } diff --git a/openstack/backend_test.go b/openstack/backend_test.go index a1f8794..dfaacb6 100644 --- a/openstack/backend_test.go +++ b/openstack/backend_test.go @@ -12,6 +12,7 @@ import ( th "github.com/gophercloud/gophercloud/testhelper" thClient "github.com/gophercloud/gophercloud/testhelper/client" "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/assert" ) @@ -44,24 +45,29 @@ func testBackend(t *testing.T, fvs ...failVerb) (*backend, logical.Storage) { config := logical.TestBackendConfig() config.StorageView = storageView + config.System = logical.TestSystemView() config.Logger = hclog.NewNullLogger() b, err := Factory(context.Background(), config) assert.NoError(t, err) + assert.NoError(t, b.Setup(context.Background(), config)) + return b.(*backend), config.StorageView } func TestBackend_sharedCloud(t *testing.T) { expected := &sharedCloud{ - client: new(gophercloud.ServiceClient), - lock: sync.Mutex{}, + client: new(gophercloud.ServiceClient), + passwords: new(Passwords), + lock: sync.Mutex{}, } cloudKey := tools.RandomString("cl", 5) back := backend{ clouds: map[string]*sharedCloud{ cloudKey: expected, }, + Backend: &framework.Backend{}, } t.Run("existing", func(t *testing.T) { diff --git a/openstack/path_cloud.go b/openstack/path_cloud.go index fd95913..3f4cb66 100644 --- a/openstack/path_cloud.go +++ b/openstack/path_cloud.go @@ -77,6 +77,11 @@ func (b *backend) pathCloud() *framework.Path { Required: true, Description: "OpenStack username of the root user.", }, + "username_template": { + Type: framework.TypeString, + Default: "vault{{random 8 | lowercase}}", + Description: "Name template for temporary generated users.", + }, "password": { Type: framework.TypeString, Required: true, @@ -85,6 +90,10 @@ func (b *backend) pathCloud() *framework.Path { Sensitive: true, }, }, + "password_policy": { + Type: framework.TypeString, + Description: "Name of the password policy to use to generate passwords for dynamic credentials.", + }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.CreateOperation: &framework.PathOperation{ @@ -149,6 +158,22 @@ func (b *backend) pathCloudCreateUpdate(ctx context.Context, r *logical.Request, if password, ok := d.GetOk("password"); ok { cloudConfig.Password = password.(string) } + if uTemplate, ok := d.GetOk("username_template"); ok { + cloudConfig.UsernameTemplate = uTemplate.(string) + // validate template first + _, err := RandomTemporaryUsername(cloudConfig.UsernameTemplate, &roleEntry{}) + if err != nil { + return logical.ErrorResponse("invalid username template: %s", err), nil + } + } + if pwdPolicy, ok := d.GetOk("password_policy"); ok { + cloudConfig.PasswordPolicy = pwdPolicy.(string) + } + + sCloud.passwords = &Passwords{ + PolicyGenerator: b.System(), + PolicyName: cloudConfig.PasswordPolicy, + } if err := cloudConfig.save(ctx, r.Storage); err != nil { return logical.ErrorResponse(err.Error()), nil @@ -169,10 +194,12 @@ func (b *backend) pathCloudRead(ctx context.Context, r *logical.Request, d *fram return &logical.Response{ Data: map[string]interface{}{ - "auth_url": cloudConfig.AuthURL, - "user_domain_name": cloudConfig.UserDomainName, - "username": cloudConfig.Username, - "password": cloudConfig.Password, + "auth_url": cloudConfig.AuthURL, + "user_domain_name": cloudConfig.UserDomainName, + "username": cloudConfig.Username, + "password": cloudConfig.Password, + "username_template": cloudConfig.UsernameTemplate, + "password_policy": cloudConfig.PasswordPolicy, }, }, nil } diff --git a/openstack/path_cloud_test.go b/openstack/path_cloud_test.go index c514cf1..1df5ce1 100644 --- a/openstack/path_cloud_test.go +++ b/openstack/path_cloud_test.go @@ -2,10 +2,11 @@ package openstack import ( "context" - "github.com/stretchr/testify/require" "strings" "testing" + "github.com/stretchr/testify/require" + "github.com/gophercloud/gophercloud/acceptance/tools" "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/assert" @@ -18,6 +19,10 @@ var ( testUserDomainName = tools.RandomString("domain", 3) testPassword1 = tools.RandomString("password1", 3) testPassword2 = tools.RandomString("password2", 3) + testTemplate1 = "asdf{{random 4}}" + testTemplate2 = "u-{{ .RoleName }}-{{ random 5 }}" + testPolicy1 = "default" + testPolicy2 = "openstack" ) func TestCloudCreate(t *testing.T) { @@ -41,11 +46,13 @@ func TestCloudCreate(t *testing.T) { Operation: logical.CreateOperation, Path: pathCloudKey(testCloudName), Data: map[string]interface{}{ - "name": testCloudName, - "auth_url": testAuthURL, - "user_domain_name": testUserDomainName, - "username": testUsername, - "password": testPassword1, + "name": testCloudName, + "auth_url": testAuthURL, + "user_domain_name": testUserDomainName, + "username": testUsername, + "password": testPassword1, + "username_template": testTemplate1, + "password_policy": testPolicy1, }, }) require.NoError(t, err) @@ -58,17 +65,20 @@ func TestCloudCreate(t *testing.T) { assert.Equal(t, cloudConfig.Username, testUsername) assert.Equal(t, cloudConfig.Password, testPassword1) assert.Equal(t, cloudConfig.Name, testCloudName) + assert.Equal(t, cloudConfig.PasswordPolicy, testPolicy1) }) t.Run("Update", func(t *testing.T) { b, storage := testBackend(t) entry, err := logical.StorageEntryJSON(storageCloudKey(testCloudName), &OsCloud{ - Name: testCloudName, - AuthURL: testAuthURL, - UserDomainName: testUserDomainName, - Username: testUsername, - Password: testPassword1, + Name: testCloudName, + AuthURL: testAuthURL, + UserDomainName: testUserDomainName, + Username: testUsername, + Password: testPassword1, + UsernameTemplate: testTemplate1, + PasswordPolicy: testPolicy1, }) require.NoError(t, err) require.NoError(t, storage.Put(context.Background(), entry)) @@ -78,16 +88,21 @@ func TestCloudCreate(t *testing.T) { require.NoError(t, err) assert.Equal(t, cloudConfig.AuthURL, testAuthURL) assert.Equal(t, cloudConfig.Password, testPassword1) + assert.Equal(t, cloudConfig.UsernameTemplate, testTemplate1) + assert.Equal(t, cloudConfig.PasswordPolicy, testPolicy1) - _, err = b.HandleRequest(context.Background(), &logical.Request{ + r, err := b.HandleRequest(context.Background(), &logical.Request{ Storage: storage, Operation: logical.UpdateOperation, Path: pathCloudKey(testCloudName), Data: map[string]interface{}{ - "password": testPassword2, + "password": testPassword2, + "username_template": testTemplate2, + "password_policy": testPolicy2, }, }) require.NoError(t, err) + require.False(t, r.IsError(), "update failed: %s", r.Error()) cloudConfig, err = sCloud.getCloudConfig(context.Background(), storage) require.NoError(t, err) @@ -96,6 +111,8 @@ func TestCloudCreate(t *testing.T) { assert.Equal(t, cloudConfig.Username, testUsername) assert.Equal(t, cloudConfig.Password, testPassword2) assert.Equal(t, cloudConfig.Name, testCloudName) + assert.Equal(t, cloudConfig.UsernameTemplate, testTemplate2) + assert.Equal(t, cloudConfig.PasswordPolicy, testPolicy2) }) t.Run("Read", func(t *testing.T) { diff --git a/openstack/path_creds.go b/openstack/path_creds.go index 0c0295e..7fe12c8 100644 --- a/openstack/path_creds.go +++ b/openstack/path_creds.go @@ -26,6 +26,13 @@ This path allows you to create OpenStack token or temporary user using predefine ` ) +type credsOpts struct { + Role *roleEntry + Config *OsCloud + PwdGenerator *Passwords + UsernameTemplate string +} + var errRootNotToken = errors.New("can't generate non-token credentials for the root user") func secretToken(b *backend) *framework.Secret { @@ -82,15 +89,15 @@ func (b *backend) pathCreds() *framework.Path { } } -func getRootCredentials(client *gophercloud.ServiceClient, role *roleEntry, config *OsCloud) (*logical.Response, error) { - if role.SecretType == SecretPassword { +func getRootCredentials(client *gophercloud.ServiceClient, container *credsOpts) (*logical.Response, error) { + if container.Role.SecretType == SecretPassword { return nil, errRootNotToken } tokenOpts := &tokens.AuthOptions{ - Username: config.Username, - Password: config.Password, - DomainName: config.UserDomainName, - Scope: *getScopeFromRole(role), + Username: container.Config.Username, + Password: container.Config.Password, + DomainName: container.Config.UserDomainName, + Scope: *getScopeFromRole(container.Role), } token, err := createToken(client, tokenOpts) @@ -99,7 +106,7 @@ func getRootCredentials(client *gophercloud.ServiceClient, role *roleEntry, conf } data := map[string]interface{}{ - "auth_url": config.AuthURL, + "auth_url": container.Config.AuthURL, "token": token.ID, "expires_at": token.ExpiresAt.String(), } @@ -110,28 +117,37 @@ func getRootCredentials(client *gophercloud.ServiceClient, role *roleEntry, conf }, InternalData: map[string]interface{}{ "secret_type": backendSecretTypeToken, - "cloud": config.Name, + "cloud": container.Config.Name, }, } return &logical.Response{Data: data, Secret: secret}, nil } -func getTmpUserCredentials(client *gophercloud.ServiceClient, role *roleEntry, config *OsCloud) (*logical.Response, error) { - password := RandomString(PwdDefaultSet, 6) - user, err := createUser(client, password, role) +func getTmpUserCredentials(client *gophercloud.ServiceClient, container *credsOpts) (*logical.Response, error) { + password, err := container.PwdGenerator.Generate(context.Background()) + if err != nil { + return nil, err + } + + username, err := RandomTemporaryUsername(container.UsernameTemplate, container.Role) + if err != nil { + return logical.ErrorResponse("error generating username for temporary user: %s", err), nil + } + + user, err := createUser(client, username, password, container.Role) if err != nil { return nil, err } var data map[string]interface{} var secretInternal map[string]interface{} - switch r := role.SecretType; r { + switch r := container.Role.SecretType; r { case SecretToken: tokenOpts := &tokens.AuthOptions{ Username: user.Name, Password: password, DomainID: user.DomainID, - Scope: *getScopeFromRole(role), + Scope: *getScopeFromRole(container.Role), } token, err := createToken(client, tokenOpts) @@ -140,27 +156,27 @@ func getTmpUserCredentials(client *gophercloud.ServiceClient, role *roleEntry, c } data = map[string]interface{}{ - "auth_url": config.AuthURL, + "auth_url": container.Config.AuthURL, "token": token.ID, "expires_at": token.ExpiresAt.String(), } secretInternal = map[string]interface{}{ "secret_type": backendSecretTypeUser, "user_id": user.ID, - "cloud": config.Name, + "cloud": container.Config.Name, } case SecretPassword: data = map[string]interface{}{ - "auth_url": config.AuthURL, + "auth_url": container.Config.AuthURL, "username": user.Name, "password": password, } switch { - case role.ProjectID != "": - data["project_id"] = role.ProjectID + case container.Role.ProjectID != "": + data["project_id"] = container.Role.ProjectID data["project_domain_id"] = user.DomainID - case role.ProjectName != "": - data["project_name"] = role.ProjectName + case container.Role.ProjectName != "": + data["project_name"] = container.Role.ProjectName data["project_domain_id"] = user.DomainID default: data["user_domain_id"] = user.DomainID @@ -169,7 +185,7 @@ func getTmpUserCredentials(client *gophercloud.ServiceClient, role *roleEntry, c secretInternal = map[string]interface{}{ "secret_type": backendSecretTypeUser, "user_id": user.ID, - "cloud": config.Name, + "cloud": container.Config.Name, } default: return nil, fmt.Errorf("invalid secret type: %s", r) @@ -179,7 +195,7 @@ func getTmpUserCredentials(client *gophercloud.ServiceClient, role *roleEntry, c Data: data, Secret: &logical.Secret{ LeaseOptions: logical.LeaseOptions{ - TTL: role.TTL * time.Second, + TTL: container.Role.TTL * time.Second, IssueTime: time.Now(), }, InternalData: secretInternal, @@ -205,11 +221,18 @@ func (b *backend) pathCredsRead(ctx context.Context, r *logical.Request, d *fram return nil, err } + container := &credsOpts{ + Role: role, + Config: cloudConfig, + PwdGenerator: sharedCloud.passwords, + UsernameTemplate: cloudConfig.UsernameTemplate, + } + if role.Root { - return getRootCredentials(client, role, cloudConfig) + return getRootCredentials(client, container) } - return getTmpUserCredentials(client, role, cloudConfig) + return getTmpUserCredentials(client, container) } func (b *backend) tokenRevoke(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -270,7 +293,7 @@ func (b *backend) userDelete(ctx context.Context, r *logical.Request, _ *framewo return &logical.Response{}, nil } -func createUser(client *gophercloud.ServiceClient, password string, role *roleEntry) (*users.User, error) { +func createUser(client *gophercloud.ServiceClient, username, password string, role *roleEntry) (*users.User, error) { token := tokens.Get(client, client.Token()) user, err := token.ExtractUser() if err != nil { @@ -296,7 +319,6 @@ func createUser(client *gophercloud.ServiceClient, password string, role *roleEn } } - username := RandomString(NameDefaultSet, 6) userCreateOpts := users.CreateOpts{ Name: username, DefaultProjectID: projectID, diff --git a/openstack/path_creds_test.go b/openstack/path_creds_test.go index 595bde7..d7c3656 100644 --- a/openstack/path_creds_test.go +++ b/openstack/path_creds_test.go @@ -36,11 +36,12 @@ func TestCredentialsRead_ok(t *testing.T) { b, s := testBackend(t) cloudEntry, err := logical.StorageEntryJSON(storageCloudKey(testCloudName), &OsCloud{ - Name: testCloudName, - AuthURL: authURL, - UserDomainName: testUserDomainName, - Username: testUsername, - Password: testPassword1, + Name: testCloudName, + AuthURL: authURL, + UserDomainName: testUserDomainName, + Username: testUsername, + Password: testPassword1, + UsernameTemplate: testTemplate1, }) require.NoError(t, err) @@ -117,6 +118,7 @@ func TestCredentialsRead_ok(t *testing.T) { Storage: s, }) require.NoError(t, err) + require.False(t, res.IsError(), res.Error()) require.NotEmpty(t, res.Data) require.NotEmpty(t, res.Data["password"]) require.NotEmpty(t, res.Secret.InternalData["user_id"]) @@ -188,11 +190,12 @@ func TestCredentialsRead_error(t *testing.T) { authURL := testClient.Endpoint + "v3" cloudEntry, err := logical.StorageEntryJSON(storageCloudKey(testCloudName), &OsCloud{ - Name: testCloudName, - AuthURL: authURL, - UserDomainName: testUserDomainName, - Username: testUsername, - Password: testPassword1, + Name: testCloudName, + AuthURL: authURL, + UserDomainName: testUserDomainName, + Username: testUsername, + Password: testPassword1, + UsernameTemplate: testTemplate1, }) require.NoError(t, err) require.NoError(t, s.Put(context.Background(), cloudEntry)) diff --git a/openstack/path_role.go b/openstack/path_role.go index 22d17ab..353f96a 100644 --- a/openstack/path_role.go +++ b/openstack/path_role.go @@ -244,7 +244,7 @@ func (b *backend) pathRoleUpdate(ctx context.Context, req *logical.Request, d *f return nil, err } if cld == nil { - return logical.ErrorResponse("cloud `%s` doesn't exist"), nil + return logical.ErrorResponse("cloud `%s` doesn't exist", cloudName), nil } name := d.Get("name").(string) @@ -323,7 +323,7 @@ func (b *backend) pathRoleUpdate(ctx context.Context, req *logical.Request, d *f return nil, err } - return &logical.Response{}, nil + return nil, nil } func (b *backend) pathRoleDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -338,7 +338,7 @@ func (b *backend) pathRoleDelete(ctx context.Context, req *logical.Request, d *f } err = req.Storage.Delete(ctx, roleStoragePath(name)) - return &logical.Response{}, err + return nil, err } func (b *backend) pathRolesList(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { diff --git a/openstack/path_rotate_root.go b/openstack/path_rotate_root.go index 825a1f2..2d891b1 100644 --- a/openstack/path_rotate_root.go +++ b/openstack/path_rotate_root.go @@ -32,16 +32,6 @@ func (b *backend) pathRotateRoot() *framework.Path { Required: true, Description: "Specifies name of the cloud which credentials will be rotated.", }, - "size": { - Type: framework.TypeInt, - Description: "Specifies the new password length.", - Default: 16, - }, - "charset": { - Type: framework.TypeString, - Description: "Specifies the new password character set.", - Default: PwdDefaultSet, - }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.ReadOperation: &framework.PathOperation{ @@ -71,10 +61,10 @@ func (b *backend) rotateRootCredentials(ctx context.Context, req *logical.Reques return nil, err } - newPassword := RandomString( - d.Get("charset").(string), - d.Get("size").(int), - ) + newPassword, err := sharedCloud.passwords.Generate(ctx) + if err != nil { + return nil, err + } // make sure we don't use this cloud until the password is changed sharedCloud.lock.Lock() diff --git a/openstack/random_string.go b/openstack/random_string.go index 328f390..c3d1bc0 100644 --- a/openstack/random_string.go +++ b/openstack/random_string.go @@ -1,8 +1,17 @@ package openstack -import "crypto/rand" +import ( + "context" + "crypto/rand" + "fmt" + + "github.com/hashicorp/vault/sdk/helper/base62" + "github.com/hashicorp/vault/sdk/helper/template" +) const ( + PasswordLength = 16 + NameDefaultSet = `0123456789abcdefghijklmnopqrstuvwxyz` PwdDefaultSet = `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@#$%^&*()_+-={}[]:"'<>,./|\'?` ) @@ -15,3 +24,39 @@ func RandomString(charset string, size int) string { } return string(bytes) } + +type usernameTemplateData struct { + CloudName string + RoleName string +} + +func RandomTemporaryUsername(templateString string, role *roleEntry) (string, error) { + t, err := template.NewTemplate(template.Template(templateString)) + if err != nil { + return "", err + } + data := usernameTemplateData{ + CloudName: role.Cloud, + RoleName: role.Name, + } + return t.Generate(data) +} + +type PasswordGenerator interface { + GeneratePasswordFromPolicy(ctx context.Context, policyName string) (password string, err error) +} + +type Passwords struct { + PolicyGenerator PasswordGenerator + PolicyName string +} + +func (p Passwords) Generate(ctx context.Context) (string, error) { + if p.PolicyName == "" { + return base62.Random(PasswordLength) + } + if p.PolicyGenerator == nil { + return "", fmt.Errorf("policy set, but no policy generator specified") + } + return p.PolicyGenerator.GeneratePasswordFromPolicy(ctx, p.PolicyName) +}