diff --git a/acceptance/static_creds_test.go b/acceptance/static_creds_test.go new file mode 100644 index 0000000..7a04a4e --- /dev/null +++ b/acceptance/static_creds_test.go @@ -0,0 +1,178 @@ +//go:build acceptance +// +build acceptance + +package acceptance + +import ( + "fmt" + "github.com/gophercloud/gophercloud" + "github.com/gophercloud/gophercloud/openstack/identity/v3/roles" + "github.com/gophercloud/gophercloud/openstack/identity/v3/users" + "github.com/opentelekomcloud/vault-plugin-secrets-openstack/openstack" + "github.com/opentelekomcloud/vault-plugin-secrets-openstack/openstack/fixtures" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +type testStaticCase struct { + cloud string + projectID string + domainID string + secretType string + username string + extensions map[string]interface{} +} + +func (p *PluginTest) TestStaticCredsLifecycle() { + t := p.T() + + cloud := openstackCloudConfig(t) + require.NotEmpty(t, cloud) + + client, aux := openstackClient(t) + + userRoleName := "member" + allRoles := getAllRoles(t, client) + + dataCloud := map[string]interface{}{ + "auth_url": cloud.AuthURL, + "username": cloud.Username, + "password": cloud.Password, + "user_domain_name": cloud.UserDomainName, + } + + cases := map[string]testStaticCase{ + "user_password": { + cloud: cloud.Name, + projectID: aux.ProjectID, + domainID: aux.DomainID, + username: "static-test-1", + secretType: "password", + }, + "user_token": { + cloud: cloud.Name, + projectID: aux.ProjectID, + domainID: aux.DomainID, + username: "static-test-2", + secretType: "token", + extensions: map[string]interface{}{ + "identity_api_version": "3", + }, + }, + } + + for name, data := range cases { + t.Run(name, func(t *testing.T) { + data := data + + roleName := openstack.RandomString(openstack.NameDefaultSet, 4) + + userId := userSetup(t, client, data, aux, allRoles[userRoleName].ID) + t.Cleanup(func() { + require.NoError(t, users.Delete(client, userId).ExtractErr()) + }) + + resp, err := p.vaultDo( + http.MethodPost, + cloudURL(cloudName), + dataCloud, + ) + require.NoError(t, err) + assertStatusCode(t, http.StatusNoContent, resp) + + resp, err = p.vaultDo( + http.MethodPost, + staticRoleURL(roleName), + cloudToStaticRoleMap(data), + ) + require.NoError(t, err) + assertStatusCode(t, http.StatusNoContent, resp) + + resp, err = p.vaultDo( + http.MethodGet, + staticRoleURL(roleName), + nil, + ) + require.NoError(t, err) + assertStatusCode(t, http.StatusOK, resp) + + resp, err = p.vaultDo( + http.MethodGet, + staticCredsURL(roleName), + nil, + ) + require.NoError(t, err) + assertStatusCode(t, http.StatusOK, resp) + + resp, err = p.vaultDo( + http.MethodDelete, + staticRoleURL(roleName), + nil, + ) + require.NoError(t, err) + assertStatusCode(t, http.StatusNoContent, resp) + + resp, err = p.vaultDo( + http.MethodDelete, + cloudURL(cloudName), + nil, + ) + require.NoError(t, err) + assertStatusCode(t, http.StatusNoContent, resp) + }) + } +} + +func staticCredsURL(roleName string) string { + return fmt.Sprintf("/v1/openstack/static-creds/%s", roleName) +} + +func cloudToStaticRoleMap(data testStaticCase) map[string]interface{} { + return fixtures.SanitizedMap(map[string]interface{}{ + "cloud": data.cloud, + "project_id": data.projectID, + "domain_id": data.domainID, + "secret_type": data.secretType, + "username": data.username, + "extensions": data.extensions, + }) +} + +func getAllRoles(t *testing.T, client *gophercloud.ServiceClient) map[string]roles.Role { + rolePages, err := roles.List(client, nil).AllPages() + require.NoError(t, err) + + roleList, err := roles.ExtractRoles(rolePages) + require.NoError(t, err) + + result := make(map[string]roles.Role, len(roleList)) + + for _, role := range roleList { + result[role.Name] = role + } + + return result +} + +func userSetup(t *testing.T, client *gophercloud.ServiceClient, data testStaticCase, aux *AuxiliaryData, roleID string) string { + createUserOpts := users.CreateOpts{ + Name: data.username, + Description: "Static user", + DefaultProjectID: aux.ProjectID, + DomainID: aux.DomainID, + Password: openstack.RandomString(openstack.PwdDefaultSet, 16), + } + user, err := users.Create(client, createUserOpts).Extract() + require.NoError(t, err) + + assignOpts := roles.AssignOpts{ + UserID: user.ID, + ProjectID: aux.ProjectID, + } + + err = roles.Assign(client, roleID, assignOpts).ExtractErr() + require.NoError(t, err) + + return user.ID +} diff --git a/acceptance/static_roles_test.go b/acceptance/static_roles_test.go index 13b8042..6c148fa 100644 --- a/acceptance/static_roles_test.go +++ b/acceptance/static_roles_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gophercloud/gophercloud/acceptance/tools" + "github.com/gophercloud/gophercloud/openstack/identity/v3/users" "github.com/hashicorp/vault/sdk/helper/jsonutil" "github.com/opentelekomcloud/vault-plugin-secrets-openstack/openstack" "github.com/stretchr/testify/assert" @@ -23,7 +24,6 @@ type staticRoleData struct { ProjectID string `json:"project_id"` ProjectName string `json:"project_name"` Extensions map[string]interface{} `json:"extensions"` - Root bool `json:"root"` SecretType string `json:"secret_type"` Username string `json:"username"` } @@ -42,17 +42,40 @@ func extractStaticRoleData(t *testing.T, resp *http.Response) *staticRoleData { func (p *PluginTest) TestStaticRoleLifecycle() { 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), - UsernameTemplate: "u-{{ .RoleName }}-{{ random 4 }}", + cloud := openstackCloudConfig(t) + require.NotEmpty(t, cloud) + + client, aux := openstackClient(t) + + dataCloud := map[string]interface{}{ + "auth_url": cloud.AuthURL, + "username": cloud.Username, + "password": cloud.Password, + "user_domain_name": cloud.UserDomainName, } - p.makeCloud(cloud) - data := expectedStaticRoleData(cloud.Name) + resp, err := p.vaultDo( + http.MethodPost, + cloudURL(cloudName), + dataCloud, + ) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode, readJSONResponse(t, resp)) + + createUserOpts := users.CreateOpts{ + Name: "vault-test", + Description: "Static user", + DomainID: aux.DomainID, + Password: openstack.RandomString(openstack.PwdDefaultSet, 16), + } + user, err := users.Create(client, createUserOpts).Extract() + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, users.Delete(client, user.ID).ExtractErr()) + }) + + data := expectedStaticRoleData(cloud.Name, aux) roleName := "test-write" t.Run("WriteRole", func(t *testing.T) { resp, err := p.vaultDo( @@ -74,12 +97,10 @@ func (p *PluginTest) TestStaticRoleLifecycle() { expected := &staticRoleData{ Cloud: data["cloud"].(string), - TTL: data["ttl"].(time.Duration), RotationDuration: data["rotation_duration"].(time.Duration), ProjectID: data["project_id"].(string), ProjectName: data["project_name"].(string), Extensions: data["extensions"].(map[string]interface{}), - Root: data["root"].(bool), SecretType: data["secret_type"].(string), Username: data["username"].(string), } @@ -112,17 +133,17 @@ func staticRoleURL(name string) string { return fmt.Sprintf("/v1/openstack/static-role/%s", name) } -func expectedStaticRoleData(cloudName string) map[string]interface{} { +func expectedStaticRoleData(cloudName string, aux *AuxiliaryData) map[string]interface{} { expectedMap := map[string]interface{}{ "cloud": cloudName, "ttl": time.Hour / time.Second, "rotation_duration": time.Hour / time.Second, - "project_id": "", + "project_id": aux.ProjectID, + "domain_id": aux.DomainID, "project_name": tools.RandomString("p", 5), "extensions": map[string]interface{}{}, - "root": false, "secret_type": "password", - "username": "static-test", + "username": "vault-test", } return expectedMap } diff --git a/doc/source/api.md b/doc/source/api.md index d093bdb..3ac99ab 100644 --- a/doc/source/api.md +++ b/doc/source/api.md @@ -440,10 +440,6 @@ created. If the role exists, it will be updated with the new attributes. - `username` `(string: )` - Specifies username of user managed by the static role. -- `root` `(bool: )` - Specifies whenever to use the root user as a role actor. - If set to `true`, `secret_type` can't be set to `password`. - If set to `true`, `ttl` value is ignored. - - `rotation_duration` `(string: "1h")` - Specifies password rotation time value for the static user as a string duration with time suffix. @@ -492,17 +488,6 @@ $ curl \ } ``` -#### Creating a static role using root user - -```json -{ - "cloud": "example-cloud", - "root": true, - "project_name": "test", - "username": "test-user" -} -``` - #### Creating a static role for password-based access ```json @@ -585,3 +570,74 @@ $ curl \ } } ``` + +## Read Static Role Credentials + +This endpoint returns user credentials based on the named static role. + +| Method | Path | +|:---------|:--------------------------------| +| `GET` | `/openstack/static-creds/:name` | + +### Parameters + +- `name` (`string: `) - Specifies the name of the role to return credentials against. + +### Sample Request + +```shell +$ curl \ + --header "X-Vault-Token: ..." \ + http://127.0.0.1:8200/v1/openstack/static-creds/example-role +``` + +### Sample Responses + +#### Credentials for the token-type static role + +```json +{ + "data": { + "auth": { + "auth_url": "https://example.com/v3/", + "token": "gAAAAABiA6Xfybumdwd84qvMDJKYOaauWxSvG9ItslSr5w0Mb...", + "project_name": "test", + "project_domain_id": "Default" + }, + "auth_type": "token" + } +} +``` + +#### Credentials for the password-type static role with project scope + +```json +{ + "data": { + "auth": { + "auth_url": "https://example.com/v3/", + "username": "admin", + "password": "RcigTiYrJjVmEkrV71Cd", + "project_name": "test", + "project_domain_id": "Default" + }, + "auth_type": "password" + } +} +``` + +#### Credentials for the password-type static role with domain scope + +```json +{ + "data": { + "auth": { + "auth_url": "https://example.com/v3/", + "username": "admin", + "password": "RcigTiYrJjVmEkrV71Cd", + "user_domain_id": "Default" + }, + "auth_type": "password" + } +} +``` diff --git a/openstack/backend.go b/openstack/backend.go index 864c3fd..03e6794 100644 --- a/openstack/backend.go +++ b/openstack/backend.go @@ -54,6 +54,7 @@ func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, b.pathStaticRole(), b.pathRotateRoot(), b.pathCreds(), + b.pathStaticCreds(), }, Secrets: []*framework.Secret{ secretToken(b), diff --git a/openstack/fixtures/helpers.go b/openstack/fixtures/helpers.go index de55543..cf2bad9 100644 --- a/openstack/fixtures/helpers.go +++ b/openstack/fixtures/helpers.go @@ -136,6 +136,66 @@ func handleCreateUser(t *testing.T, w http.ResponseWriter, r *http.Request, user `, userID) } +func handleUpdateUser(t *testing.T, w http.ResponseWriter, r *http.Request, userID string) { + t.Helper() + + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestMethod(t, r, "PATCH") + + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, ` +{ + "user": { + "default_project_id": "project", + "description": "James Doe user", + "domain_id": "domain", + "email": "jdoe@example.com", + "enabled": true, + "id": "%s", + "links": { + "self": "https://example.com/identity/v3/users/29148f9awu90f1u2" + }, + "name": "James Doe", + "password_expires_at": "2016-11-06T15:32:17.000000" + } +} +`, userID) +} + +func handleListUsers(t *testing.T, w http.ResponseWriter, r *http.Request, userID string, userName string) { + t.Helper() + + th.TestHeader(t, r, "Accept", "application/json") + th.TestMethod(t, r, "GET") + + w.Header().Add("Content-Type", "application/json") + + _, _ = fmt.Fprintf(w, ` +{ + "users": [ + { + "default_project_id": "project", + "description": "James Doe user", + "domain_id": "domain", + "email": "jdoe@example.com", + "enabled": true, + "id": "%s", + "links": { + "self": "https://example.com/identity/v3/users/29148f9awu90f1u2" + }, + "name": "%s", + "password_expires_at": "2016-11-06T15:32:17.000000" + } + ], + "links": { + "next": null, + "previous": null + } +} +`, userID, userName) +} + func handleProjectList(t *testing.T, w http.ResponseWriter, r *http.Request, projectName string) { t.Helper() @@ -179,6 +239,8 @@ type EnabledMocks struct { PasswordChange bool ProjectList bool UserPost bool + UserPatch bool + UserList bool UserDelete bool } @@ -213,6 +275,10 @@ func SetupKeystoneMock(t *testing.T, userID, projectName string, enabled Enabled if enabled.UserPost { handleCreateUser(t, w, r, userID) } + case "GET": + if enabled.UserList { + handleListUsers(t, w, r, userID, projectName) + } default: w.WriteHeader(404) } @@ -239,6 +305,14 @@ func SetupKeystoneMock(t *testing.T, userID, projectName string, enabled Enabled }) } + if enabled.UserPatch { + th.Mux.HandleFunc(fmt.Sprintf("/v3/users/%s", userID), func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PATCH") + + handleUpdateUser(t, w, r, userID) + }) + } + if enabled.UserDelete { th.Mux.HandleFunc(fmt.Sprintf("/v3/users/%s", userID), func(w http.ResponseWriter, r *http.Request) { th.TestHeader(t, r, "Accept", "application/json") diff --git a/openstack/path_static_creds.go b/openstack/path_static_creds.go new file mode 100644 index 0000000..20083df --- /dev/null +++ b/openstack/path_static_creds.go @@ -0,0 +1,213 @@ +package openstack + +import ( + "context" + "fmt" + "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens" + "github.com/gophercloud/gophercloud/openstack/identity/v3/users" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + pathStaticCreds = "static-creds" + + staticCredsHelpSyn = "Manage the Openstack static credentials with static roles." + staticCredsHelpDesc = ` +This path allows you to read OpenStack secret stored by predefined static roles. +` +) + +func (b *backend) pathStaticCreds() *framework.Path { + return &framework.Path{ + Pattern: fmt.Sprintf("%s/%s", pathStaticCreds, framework.GenericNameRegex("role")), + Fields: map[string]*framework.FieldSchema{ + "role": { + Type: framework.TypeString, + Description: "Name of the role.", + Required: true, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathStaticCredsRead, + }, + }, + HelpSynopsis: staticCredsHelpSyn, + HelpDescription: staticCredsHelpDesc, + } +} + +func (b *backend) pathStaticCredsRead(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) { + roleName := d.Get("role").(string) + role, err := getStaticRoleByName(ctx, roleName, r) + if err != nil { + return nil, err + } + + sharedCloud := b.getSharedCloud(role.Cloud) + cloudConfig, err := sharedCloud.getCloudConfig(ctx, r.Storage) + if err != nil { + return nil, err + } + + client, err := sharedCloud.getClient(ctx, r.Storage) + if err != nil { + return nil, err + } + + var data map[string]interface{} + switch r := role.SecretType; r { + case SecretToken: + tokenOpts := &tokens.AuthOptions{ + Username: role.Username, + Password: role.Secret, + DomainID: role.DomainID, + Scope: getScopeFromStaticRole(role), + } + + token, err := createToken(client, tokenOpts) + if err != nil { + return nil, err + } + + authResponse := &authStaticResponseData{ + AuthURL: cloudConfig.AuthURL, + Username: role.Username, + Token: token.ID, + DomainID: role.DomainID, + } + + data = map[string]interface{}{ + "auth": formStaticAuthResponse( + role, + authResponse, + ), + "auth_type": "token", + } + + case SecretPassword: + authResponse := &authStaticResponseData{ + AuthURL: cloudConfig.AuthURL, + Username: role.Username, + Password: role.Secret, + DomainID: role.DomainID, + } + data = map[string]interface{}{ + "auth": formStaticAuthResponse( + role, + authResponse, + ), + "auth_type": "password", + } + + default: + return nil, fmt.Errorf("invalid secret type: %s", r) + } + + for extensionKey, extensionValue := range role.Extensions { + data[extensionKey] = extensionValue + } + + return &logical.Response{Data: data}, nil +} + +func getScopeFromStaticRole(role *roleStaticEntry) tokens.Scope { + var scope tokens.Scope + switch { + case role.ProjectID != "": + scope = tokens.Scope{ + ProjectID: role.ProjectID, + } + case role.ProjectName != "": + scope = tokens.Scope{ + ProjectName: role.ProjectName, + DomainName: role.DomainName, + DomainID: role.DomainID, + } + case role.DomainID != "": + scope = tokens.Scope{ + DomainID: role.DomainID, + } + case role.DomainName != "": + scope = tokens.Scope{ + DomainName: role.DomainName, + } + default: + scope = tokens.Scope{} + } + return scope +} + +type authStaticResponseData struct { + AuthURL string + Username string + Password string + Token string + DomainID string + DomainName string +} + +func formStaticAuthResponse(role *roleStaticEntry, authResponse *authStaticResponseData) map[string]interface{} { + var auth map[string]interface{} + + switch { + case role.ProjectID != "": + auth = map[string]interface{}{ + "project_id": role.ProjectID, + } + case role.ProjectName != "": + auth = map[string]interface{}{ + "project_name": role.ProjectName, + "project_domain_id": authResponse.DomainID, + } + default: + + auth = map[string]interface{}{ + "user_domain_id": authResponse.DomainID, + } + } + + if authResponse.Token != "" { + auth["token"] = authResponse.Token + } else { + auth["username"] = authResponse.Username + auth["password"] = authResponse.Password + } + + auth["auth_url"] = authResponse.AuthURL + + return auth +} + +func (b *backend) rotateUserPassword(ctx context.Context, req *logical.Request, cloud *sharedCloud, user string, password string) (string, error) { + var userId string + client, err := cloud.getClient(ctx, req.Storage) + if err != nil { + return userId, err + } + opts := users.ListOpts{Name: user} + allPages, err := users.List(client, opts).AllPages() + if err != nil { + return userId, fmt.Errorf("provided user doesn't exist") + } + + allUsers, err := users.ExtractUsers(allPages) + if err != nil { + return userId, fmt.Errorf("page can't be extracted for given username: %s (%s)", user, err) + } + + if len(allUsers) > 1 { + return userId, fmt.Errorf("given username is not unique") + } else if len(allUsers) == 0 { + return userId, fmt.Errorf("user `%s` doesn't exist", user) + } + + userId = allUsers[0].ID + + _, err = users.Update(client, userId, users.UpdateOpts{Password: password}).Extract() + if err != nil { + return userId, fmt.Errorf("error rotating user password for user `%s`: %s", user, err) + } + return userId, nil +} diff --git a/openstack/path_static_creds_test.go b/openstack/path_static_creds_test.go new file mode 100644 index 0000000..e0ce9ca --- /dev/null +++ b/openstack/path_static_creds_test.go @@ -0,0 +1,163 @@ +package openstack + +import ( + "context" + "fmt" + "testing" + + "github.com/gophercloud/gophercloud/acceptance/tools" + thClient "github.com/gophercloud/gophercloud/testhelper/client" + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/sdk/logical" + "github.com/opentelekomcloud/vault-plugin-secrets-openstack/openstack/fixtures" + "github.com/stretchr/testify/require" +) + +func credsStaticPath(name string) string { + return fmt.Sprintf("%s/%s", "static-creds", name) +} + +func TestStaticCredentialsRead_ok(t *testing.T) { + userID, _ := uuid.GenerateUUID() + secret, _ := uuid.GenerateUUID() + projectName := tools.RandomString("p", 5) + + fixtures.SetupKeystoneMock(t, userID, projectName, fixtures.EnabledMocks{ + TokenPost: true, + TokenGet: true, + UserList: true, + }) + + testClient := thClient.ServiceClient() + authURL := testClient.Endpoint + "v3" + + b, s := testBackend(t) + cloudEntry, err := logical.StorageEntryJSON(storageCloudKey(testCloudName), &OsCloud{ + Name: testCloudName, + AuthURL: authURL, + UserDomainName: testUserDomainName, + Username: testUsername, + Password: testPassword1, + UsernameTemplate: testTemplate1, + }) + require.NoError(t, err) + + t.Run("user_token", func(t *testing.T) { + require.NoError(t, s.Put(context.Background(), cloudEntry)) + + roleName := createSaveRandomStaticRole(t, s, projectName, "token", secret) + + res, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: credsStaticPath(roleName), + Storage: s, + }) + require.NoError(t, err) + require.NotEmpty(t, res.Data) + }) + t.Run("user_password", func(t *testing.T) { + require.NoError(t, s.Put(context.Background(), cloudEntry)) + + roleName := createSaveRandomStaticRole(t, s, projectName, "password", secret) + + res, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: credsStaticPath(roleName), + Storage: s, + }) + require.NoError(t, err) + require.NotEmpty(t, res.Data) + }) +} + +func TestStaticCredentialsRead_error(t *testing.T) { + t.Run("read-fail", func(t *testing.T) { + userID, _ := uuid.GenerateUUID() + secret, _ := uuid.GenerateUUID() + fixtures.SetupKeystoneMock(t, userID, "", fixtures.EnabledMocks{}) + + b, s := testBackend(t, failVerbRead) + + roleName := createSaveRandomStaticRole(t, s, "", "token", secret) + + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Path: credsStaticPath(roleName), + Operation: logical.ReadOperation, + Storage: s, + }) + require.Error(t, err) + }) + + type testCase struct { + EnabledMocks fixtures.EnabledMocks + ProjectName string + ServiceType string + } + + cases := map[string]testCase{ + "no-token-post": { + EnabledMocks: fixtures.EnabledMocks{ + TokenGet: true, + }, + ProjectName: tools.RandomString("p", 5), + ServiceType: "token", + }, + "no-token-get": { + EnabledMocks: fixtures.EnabledMocks{ + TokenPost: true, + }, + ProjectName: tools.RandomString("p", 5), + ServiceType: "token", + }, + } + + for name, data := range cases { + t.Run(name, func(t *testing.T) { + data := data + userID, _ := uuid.GenerateUUID() + secret, _ := uuid.GenerateUUID() + fixtures.SetupKeystoneMock(t, userID, data.ProjectName, data.EnabledMocks) + + b, s := testBackend(t) + + roleName := createSaveRandomStaticRole(t, s, data.ProjectName, data.ServiceType, secret) + + testClient := thClient.ServiceClient() + authURL := testClient.Endpoint + "v3" + + cloudEntry, err := logical.StorageEntryJSON(storageCloudKey(testCloudName), &OsCloud{ + Name: testCloudName, + AuthURL: authURL, + UserDomainName: testUserDomainName, + Username: testUsername, + Password: testPassword1, + UsernameTemplate: testTemplate1, + }) + require.NoError(t, err) + require.NoError(t, s.Put(context.Background(), cloudEntry)) + + _, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: credsStaticPath(roleName), + Storage: s, + }) + require.Error(t, err) + }) + } +} + +func createSaveRandomStaticRole(t *testing.T, s logical.Storage, projectName, sType string, secret string) string { + roleName := randomRoleName() + role := map[string]interface{}{ + "name": roleName, + "cloud": testCloudName, + "project_name": projectName, + "domain_id": tools.RandomString("d", 5), + "secret_type": sType, + "secret": secret, + "username": roleName, + } + saveRawStaticRole(t, roleName, role, s) + + return roleName +} diff --git a/openstack/path_static_role.go b/openstack/path_static_role.go index 52eb10c..0704b88 100644 --- a/openstack/path_static_role.go +++ b/openstack/path_static_role.go @@ -60,19 +60,14 @@ func (b *backend) pathStaticRole() *framework.Path { Type: framework.TypeString, Description: "Specifies root configuration of the created static role.", }, - "root": { - Type: framework.TypeBool, - Description: "Specifies whenever to use the root static user as a role actor.", - Default: false, - }, - "ttl": { + "rotation_duration": { Type: framework.TypeDurationSecond, - Description: "Specifies password rotation time left until next password rotation..", + Description: "Specifies the duration of static role password rotation.", Default: "1h", }, - "rotation_duration": { + "ttl": { Type: framework.TypeDurationSecond, - Description: "Specifies the duration of static role password rotation.", + Description: "Internal field which specifies the remaining time for the next password rotation.", Default: "1h", }, "secret_type": { @@ -81,9 +76,18 @@ func (b *backend) pathStaticRole() *framework.Path { AllowedValues: []interface{}{"token", "password"}, Default: SecretToken, }, + "secret": { + Type: framework.TypeString, + Description: "Internal field for Openstack user password which will be rotated " + + "upon static role creation.", + }, "username": { Type: framework.TypeNameString, - Description: "Specifies a domain name for domain-scoped role.", + Description: "Specifies a username for static role.", + }, + "user_id": { + Type: framework.TypeNameString, + Description: "Internal field with static user id for further user management. Set once on role creation", }, "project_id": { Type: framework.TypeLowerCaseString, @@ -138,11 +142,12 @@ func (b *backend) staticRoleExistenceCheck(ctx context.Context, r *logical.Reque type roleStaticEntry struct { Name string `json:"name"` Cloud string `json:"cloud"` - Root bool `json:"root"` TTL time.Duration `json:"ttl,omitempty"` RotationDuration time.Duration `json:"rotation_duration,omitempty"` SecretType secretType `json:"secret_type"` + Secret string `json:"secret"` Username string `json:"username"` + UserID string `json:"user_id"` ProjectID string `json:"project_id"` ProjectName string `json:"project_name"` DomainID string `json:"domain_id"` @@ -188,8 +193,6 @@ func getStaticRoleByName(ctx context.Context, name string, s *logical.Request) ( func staticRoleToMap(src *roleStaticEntry) map[string]interface{} { return map[string]interface{}{ "cloud": src.Cloud, - "root": src.Root, - "ttl": src.RotationDuration, "rotation_duration": src.RotationDuration, "secret_type": string(src.SecretType), "username": src.Username, @@ -218,7 +221,6 @@ func (b *backend) pathStaticRoleRead(ctx context.Context, req *logical.Request, func (b *backend) pathStaticRoleUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { var cloudName string - var userName string if cloud, ok := d.GetOk("cloud"); ok { cloudName = cloud.(string) } else { @@ -227,19 +229,12 @@ func (b *backend) pathStaticRoleUpdate(ctx context.Context, req *logical.Request } } - if username, ok := d.GetOk("username"); ok { - userName = username.(string) - } else { - if req.Operation == logical.CreateOperation { - return logical.ErrorResponse("username is required when creating a static role"), nil - } - } - - cld, err := b.getSharedCloud(cloudName).getCloudConfig(ctx, req.Storage) + cloud := b.getSharedCloud(cloudName) + cloudConfig, err := cloud.getCloudConfig(ctx, req.Storage) if err != nil { return nil, err } - if cld == nil { + if cloudConfig == nil { return logical.ErrorResponse("cloud `%s` doesn't exist", cloudName), nil } @@ -259,30 +254,34 @@ func (b *backend) pathStaticRoleUpdate(ctx context.Context, req *logical.Request entry = &roleStaticEntry{Name: name, Cloud: cloudName} } - entry.Username = userName + if username, ok := d.GetOk("username"); ok && req.Operation == logical.CreateOperation { + entry.Username = username.(string) + password, err := Passwords{}.Generate(ctx) + if err != nil { + return nil, err + } + + userId, err := b.rotateUserPassword(ctx, req, cloud, username.(string), password) + if err != nil { + return logical.ErrorResponse("error during role creation: %s", err), nil + } + + entry.UserID = userId + entry.Secret = password - if isRoot, ok := d.GetOk("root"); ok { - entry.Root = isRoot.(bool) + } else if req.Operation == logical.CreateOperation { + return logical.ErrorResponse("username is required when creating a static role"), nil } - if !entry.Root { - if rotation, ok := d.GetOk("rotation_duration"); ok { - entry.RotationDuration = time.Duration(rotation.(int)) - entry.TTL = time.Duration(rotation.(int)) - } else if req.Operation == logical.CreateOperation { - entry.RotationDuration = time.Hour / time.Second - entry.TTL = time.Hour / time.Second - } - } else { - if _, ok := d.GetOk("rotation_duration"); ok { - return logical.ErrorResponse(errInvalidForRoot, "rotation_duration"), nil - } + if rotation, ok := d.GetOk("rotation_duration"); ok { + entry.RotationDuration = time.Duration(rotation.(int)) + entry.TTL = time.Duration(rotation.(int)) + } else if req.Operation == logical.CreateOperation { + entry.RotationDuration = time.Hour / time.Second + entry.TTL = time.Hour / time.Second } if typ, ok := d.GetOk("secret_type"); ok { - if entry.Root && typ != SecretToken { - return logical.ErrorResponse(errInvalidForRoot, "secret type"), nil - } entry.SecretType = secretType(typ.(string)) } else if req.Operation == logical.CreateOperation { entry.SecretType = SecretToken diff --git a/openstack/path_static_role_test.go b/openstack/path_static_role_test.go index 33c5200..c50ee96 100644 --- a/openstack/path_static_role_test.go +++ b/openstack/path_static_role_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gophercloud/gophercloud/acceptance/tools" + thClient "github.com/gophercloud/gophercloud/testhelper/client" "github.com/hashicorp/go-uuid" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/logical" @@ -37,13 +38,11 @@ func expectedStaticRoleData(cloudName string) (*roleStaticEntry, map[string]inte } expectedMap := map[string]interface{}{ "cloud": expected.Cloud, - "ttl": expTTL, "project_id": "", "project_name": expected.ProjectName, "domain_id": "", "domain_name": expected.DomainName, "extensions": map[string]string{}, - "root": false, "rotation_duration": expTTL, "secret_type": "token", "username": "static-test", @@ -194,7 +193,7 @@ func TestStaticRoleList(t *testing.T) { b, s := testBackend(t, failVerbList) _, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ListOperation, - Path: "roles/", + Path: "static-roles/", Storage: s, }) require.Error(t, err) @@ -309,49 +308,61 @@ func TestStaticRoleDelete(t *testing.T) { } func TestStaticRoleCreate(t *testing.T) { + username := "James_Doe" + userID, _ := uuid.GenerateUUID() + + fixtures.SetupKeystoneMock(t, userID, username, fixtures.EnabledMocks{ + TokenPost: true, + TokenGet: true, + UserPatch: true, + UserList: true, + }) + + testClient := thClient.ServiceClient() + authURL := testClient.Endpoint + "v3" + + b, s := testBackend(t) + cloudEntry, err := logical.StorageEntryJSON(storageCloudKey(testCloudName), &OsCloud{ + Name: testCloudName, + AuthURL: authURL, + UserDomainName: testUserDomainName, + Username: testUsername, + Password: testPassword1, + UsernameTemplate: testTemplate1, + }) + require.NoError(t, err) + t.Parallel() - username := tools.RandomString("user", 5) - id, _ := uuid.GenerateUUID() - t.Run("ok", func(t *testing.T) { - b, s := testBackend(t) - cloudName := preCreateCloud(t, s) + t.Run("ok", func(t *testing.T) { cases := map[string]*roleStaticEntry{ - "admin": { - Name: randomRoleName(), - Cloud: cloudName, - Root: true, - Username: username, - }, "token": { Name: randomRoleName(), - Cloud: cloudName, + Cloud: testCloudName, ProjectName: randomRoleName(), SecretType: SecretToken, Username: username, }, "password": { Name: randomRoleName(), - Cloud: cloudName, + Cloud: testCloudName, ProjectName: randomRoleName(), SecretType: SecretPassword, Username: username, }, "rotation_duration": { Name: randomRoleName(), - Cloud: cloudName, + Cloud: testCloudName, ProjectName: randomRoleName(), SecretType: SecretToken, Username: username, RotationDuration: 24 * time.Hour, - TTL: 24 * time.Hour, }, "endpoint-override": { - Name: randomRoleName(), - Cloud: cloudName, - Username: username, - ProjectID: id, + Name: randomRoleName(), + Cloud: testCloudName, + Username: username, Extensions: map[string]string{ "volume_api_version": "3", "object_store_endpoint_override": "https://swift.example.com", @@ -363,7 +374,7 @@ func TestStaticRoleCreate(t *testing.T) { t.Run(name, func(t *testing.T) { data := data t.Parallel() - + require.NoError(t, s.Put(context.Background(), cloudEntry)) roleName := data.Name inputRole := fixtures.SanitizedMap(staticRoleToMap(data)) @@ -382,7 +393,8 @@ func TestStaticRoleCreate(t *testing.T) { role := new(roleStaticEntry) assert.NoError(t, entry.DecodeJSON(role)) - fillStaticRoleDefaultFields(b, data) // otherwise there will be false positives + fillActualStaticRoleDefaultFields(role) + fillExpectedStaticRoleDefaultFields(b, data) // otherwise there will be false positives assert.Equal(t, data, role) }) } @@ -397,25 +409,13 @@ func TestStaticRoleCreate(t *testing.T) { b, s := testBackend(t) cloudName := preCreateCloud(t, s) - notForRootRe := regexp.MustCompile(`impossible to set .+ for the root user`) cases := map[string]*errRoleEntry{ - "root-ttl": { + "username": { roleStaticEntry: &roleStaticEntry{ Cloud: cloudName, - Username: username, - Root: true, RotationDuration: 1 * time.Hour, }, - errorRegex: notForRootRe, - }, - "root-password": { - roleStaticEntry: &roleStaticEntry{ - Cloud: cloudName, - Username: username, - Root: true, - SecretType: SecretPassword, - }, - errorRegex: notForRootRe, + errorRegex: regexp.MustCompile(`username is required when creating a static role`), }, "without-cloud": { roleStaticEntry: &roleStaticEntry{}, @@ -519,18 +519,21 @@ func TestStaticRoleUpdate(t *testing.T) { }) } -func fillStaticRoleDefaultFields(b *backend, entry *roleStaticEntry) { +func fillExpectedStaticRoleDefaultFields(b *backend, entry *roleStaticEntry) { pr := b.pathStaticRole() flds := pr.Fields if entry.SecretType == "" { entry.SecretType = flds["secret_type"].Default.(secretType) } - if !entry.Root { - if entry.RotationDuration == 0 { - entry.RotationDuration = time.Hour - entry.TTL = time.Hour - } + + if entry.RotationDuration == 0 { + entry.RotationDuration = time.Hour } - entry.TTL /= time.Second entry.RotationDuration /= time.Second } + +func fillActualStaticRoleDefaultFields(entry *roleStaticEntry) { + entry.Secret = "" + entry.UserID = "" + entry.TTL = 0 +}