diff --git a/doc/source/api.md b/doc/source/api.md index fcbb9b6..5153851 100644 --- a/doc/source/api.md +++ b/doc/source/api.md @@ -22,6 +22,8 @@ will overwrite them. * `password` `(string: )` - OpenStack password of the root user. +* `root_password_ttl` `(string: )` - Password rotation period. Default period is six month. + * `username_template` `(string: "vault{{random 8 | lowercase}}")` - 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 @@ -39,7 +41,8 @@ will overwrite them. "username": "admin", "password": "RcigTiYrJjVmEkrV71Cd", "user_domain_name": "Default", - "username_template": "user-{{ .RoleName }}-{{ random 4 }}" + "username_template": "user-{{ .RoleName }}-{{ random 4 }}", + "root_password_ttl": "5h" } ``` diff --git a/openstack/backend.go b/openstack/backend.go index 182f4e2..46bba4b 100644 --- a/openstack/backend.go +++ b/openstack/backend.go @@ -147,13 +147,3 @@ func (c *sharedCloud) initClient(ctx context.Context, s logical.Storage) error { return nil } - -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"` - UsernameTemplate string `json:"username_template"` - PasswordPolicy string `json:"password_policy"` -} diff --git a/openstack/path_cloud.go b/openstack/path_cloud.go index 22867b2..2a15d88 100644 --- a/openstack/path_cloud.go +++ b/openstack/path_cloud.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" "github.com/opentelekomcloud/vault-plugin-secrets-openstack/vars" + "time" ) const ( @@ -20,6 +21,7 @@ Configure the root credentials for an OpenStack cloud using the above parameters pathCloudListHelpDesc = `List existing OpenStack clouds by name.` DefaultUsernameTemplate = "vault{{random 8 | lowercase}}" + defaultRootPasswordTTL = 4380 * time.Hour ) func storageCloudKey(name string) string { @@ -30,6 +32,18 @@ func pathCloudKey(name string) string { return fmt.Sprintf("%s/%s", pathCloud, name) } +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"` + UsernameTemplate string `json:"username_template"` + PasswordPolicy string `json:"password_policy"` + RootPasswordTTL time.Duration `json:"root_password_ttl"` + RootPasswordExpirationDate time.Time `json:"root_password_expiration_date"` +} + func (c *sharedCloud) getCloudConfig(ctx context.Context, s logical.Storage) (*OsCloud, error) { entry, err := s.Get(ctx, storageCloudKey(c.name)) if err != nil { @@ -95,6 +109,12 @@ func (b *backend) pathCloud() *framework.Path { Type: framework.TypeString, Description: "Name of the password policy to use to generate passwords for dynamic credentials.", }, + "root_password_ttl": { + Type: framework.TypeDurationSecond, + Default: defaultRootPasswordTTL, + Description: "The TTL of the root password for openstack user. This can be either a number of seconds or a time formatted duration (ex: 24h, 48ds)", + Required: false, + }, }, Operations: map[logical.Operation]framework.OperationHandler{ logical.CreateOperation: &framework.PathOperation{ @@ -176,13 +196,21 @@ func (b *backend) pathCloudCreateUpdate(ctx context.Context, r *logical.Request, if err != nil { return logical.ErrorResponse("invalid username template: %w", err), nil } - } else if r.Operation == logical.CreateOperation { + } else if r.Operation == logical.CreateOperation && cloudConfig.UsernameTemplate == "" { cloudConfig.UsernameTemplate = DefaultUsernameTemplate } if pwdPolicy, ok := d.GetOk("password_policy"); ok { cloudConfig.PasswordPolicy = pwdPolicy.(string) } + if rootExpirationRaw, ok := d.GetOk("root_password_ttl"); ok { + cloudConfig.RootPasswordTTL = time.Second * time.Duration(rootExpirationRaw.(int)) + } else if r.Operation == logical.CreateOperation && cloudConfig.RootPasswordTTL == 0 { + cloudConfig.RootPasswordTTL = defaultRootPasswordTTL + } + + cloudConfig.RootPasswordExpirationDate = time.Now().Add(cloudConfig.RootPasswordTTL) + sCloud.passwords = &Passwords{ PolicyGenerator: b.System(), PolicyName: cloudConfig.PasswordPolicy, @@ -212,6 +240,8 @@ func (b *backend) pathCloudRead(ctx context.Context, r *logical.Request, d *fram "username": cloudConfig.Username, "username_template": cloudConfig.UsernameTemplate, "password_policy": cloudConfig.PasswordPolicy, + "root_password_ttl": int(cloudConfig.RootPasswordTTL.Seconds()), + "next_rotation": cloudConfig.RootPasswordExpirationDate.Format(time.RFC822), }, }, nil } diff --git a/openstack/path_cloud_test.go b/openstack/path_cloud_test.go index 13435d8..16851c3 100644 --- a/openstack/path_cloud_test.go +++ b/openstack/path_cloud_test.go @@ -2,14 +2,12 @@ package openstack import ( "context" - "strings" - "testing" - - "github.com/stretchr/testify/require" - "github.com/gophercloud/gophercloud/acceptance/tools" "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "strings" + "testing" ) var ( @@ -25,7 +23,7 @@ var ( testPolicy2 = "openstack" ) -func TestCloudCreate(t *testing.T) { +func TestLifecyle(t *testing.T) { t.Run("EmptyConfig", func(t *testing.T) { b, storage := testBackend(t) @@ -199,3 +197,91 @@ func TestCloudCreate(t *testing.T) { assert.Len(t, res.Data["keys"], cloudCount) }) } + +func TestConfig(t *testing.T) { + b, s := testBackend(t) + + tests := []struct { + name string + config map[string]interface{} + expected map[string]interface{} + wantErr bool + }{ + { + name: "root_password_ttl defaults to 6 months", + config: map[string]interface{}{ + "auth_url": "https://test-001.com/v3", + "username": "test-username-1", + "user_domain_name": "testUserDomainName", + "password": "testUserPassword", + "username_template": "user-{{ .RoleName }}-{{ random 4 }}", + }, + expected: map[string]interface{}{ + "auth_url": "https://test-001.com/v3", + "username": "test-username-1", + "user_domain_name": "testUserDomainName", + "username_template": "user-{{ .RoleName }}-{{ random 4 }}", + "root_password_ttl": 15768000, + "password_policy": "", + }, + }, + { + name: "root_password_ttl is provided", + config: map[string]interface{}{ + "auth_url": "https://test-001.com/v3", + "username": "test-username-2", + "user_domain_name": "testUserDomainName", + "password": "testUserPassword", + "root_password_ttl": "1m", + }, + expected: map[string]interface{}{ + "auth_url": "https://test-001.com/v3", + "username": "test-username-2", + "user_domain_name": "testUserDomainName", + "password_policy": "", + "root_password_ttl": 60, + "username_template": "vault{{random 8 | lowercase}}"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var cloudName = strings.ToLower(tools.RandomString("cloud", 3)) + testConfigCreateUpdate(t, b, s, tc.config, cloudName) + testConfigRead(t, b, s, tc.expected, cloudName) + + // Test that updating one element retains the others + tc.expected["user_domain_name"] = "800e371d-ee51-4145-9ac8-5c43e4ceb79b" + configSubset := map[string]interface{}{ + "user_domain_name": "800e371d-ee51-4145-9ac8-5c43e4ceb79b", + } + + testConfigCreateUpdate(t, b, s, configSubset, cloudName) + testConfigRead(t, b, s, tc.expected, cloudName) + }) + } +} + +func testConfigCreateUpdate(t *testing.T, b logical.Backend, s logical.Storage, expected map[string]interface{}, name string) { + t.Helper() + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: s, + Operation: logical.CreateOperation, + Path: pathCloudKey(name), + Data: expected, + }) + require.NoError(t, err) +} + +func testConfigRead(t *testing.T, b logical.Backend, s logical.Storage, expected map[string]interface{}, name string) { + t.Helper() + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Storage: s, + Operation: logical.ReadOperation, + Path: pathCloudKey(name), + }) + require.NoError(t, err) + + expected["next_rotation"] = resp.Data["next_rotation"] + assert.Equal(t, expected, resp.Data) +}