From 2f5bfa8491877aa8d7b8a82b36b33ef230504919 Mon Sep 17 00:00:00 2001 From: Artem Lifshits <55093318+artem-lifshits@users.noreply.github.com> Date: Mon, 21 Nov 2022 18:37:14 +0100 Subject: [PATCH] Vault: user_domain_id/name and project_domain_id/name implementation for dynamic users (#119) Vault: user_domain_id/name and project_domain_id/name implementation for dynamic users Implementation of user_domain_id/name and project_domain_id/name for dynamic users. Changes include: path_roles.go path_roles_test.go api.md path_creds.go path_creds_test.go tests for new parameters Reviewed-by: Aloento Reviewed-by: Anton Sidelnikov Reviewed-by: Polina Gubina Reviewed-by: Artem Lifshits --- acceptance/creds_test.go | 49 +++++++---- acceptance/roles_test.go | 1 - doc/source/api.md | 16 +++- openstack/common/gopher_api.go | 18 ++++ openstack/fixtures/helpers.go | 146 +++++++++++++++++++++++++++++---- openstack/path_creds.go | 67 +++++++++++++-- openstack/path_creds_test.go | 43 ++++++++-- openstack/path_role.go | 92 +++++++++++++++------ openstack/path_role_test.go | 36 ++++---- 9 files changed, 382 insertions(+), 86 deletions(-) create mode 100644 openstack/common/gopher_api.go diff --git a/acceptance/creds_test.go b/acceptance/creds_test.go index 0d7a50c..dc8419c 100644 --- a/acceptance/creds_test.go +++ b/acceptance/creds_test.go @@ -6,6 +6,7 @@ package acceptance import ( "fmt" "net/http" + "os" "testing" "github.com/opentelekomcloud/vault-plugin-secrets-openstack/openstack" @@ -15,18 +16,22 @@ import ( ) type testCase struct { - Cloud string - ProjectID string - DomainID string - Root bool - SecretType string - UserRoles []string - Extensions map[string]interface{} + Cloud string + ProjectID string + DomainID string + UserDomainID string + UserDomainName string + Root bool + SecretType string + UserRoles []string + UserGroups []string + Extensions map[string]interface{} } func (p *PluginTest) TestCredsLifecycle() { t := p.T() + userDomainID := os.Getenv("USER_DOMAIN_ID") cloud := openstackCloudConfig(t) require.NotEmpty(t, cloud) @@ -45,7 +50,7 @@ func (p *PluginTest) TestCredsLifecycle() { DomainID: aux.DomainID, Root: false, SecretType: "token", - UserRoles: []string{"member"}, + UserGroups: []string{"mygroup"}, Extensions: map[string]interface{}{ "identity_api_version": "3", }, @@ -60,6 +65,17 @@ func (p *PluginTest) TestCredsLifecycle() { "object_store_endpoint_override": "https://swift.example.com", }, }, + "user_domain_id_token": { + Cloud: cloud.Name, + ProjectID: aux.ProjectID, + UserDomainID: userDomainID, + Root: false, + SecretType: "token", + UserRoles: []string{"member"}, + Extensions: map[string]interface{}{ + "identity_api_version": "3", + }, + }, } for name, data := range cases { @@ -154,12 +170,15 @@ func cloudToCloudMap(cloud *openstack.OsCloud) map[string]interface{} { func cloudToRoleMap(data testCase) map[string]interface{} { return fixtures.SanitizedMap(map[string]interface{}{ - "cloud": data.Cloud, - "project_id": data.ProjectID, - "domain_id": data.DomainID, - "root": data.Root, - "secret_type": data.SecretType, - "user_roles": data.UserRoles, - "extensions": data.Extensions, + "cloud": data.Cloud, + "project_id": data.ProjectID, + "user_domain_id": data.UserDomainID, + "user_domain_name": data.UserDomainName, + "domain_id": data.DomainID, + "root": data.Root, + "secret_type": data.SecretType, + "user_roles": data.UserRoles, + "user_groups": data.UserGroups, + "extensions": data.Extensions, }) } diff --git a/acceptance/roles_test.go b/acceptance/roles_test.go index 93828e5..76d5884 100644 --- a/acceptance/roles_test.go +++ b/acceptance/roles_test.go @@ -40,7 +40,6 @@ func extractRoleData(t *testing.T, resp *http.Response) *roleData { func (p *PluginTest) TestRoleLifecycle() { t := p.T() - cloud := openstackCloudConfig(t) require.NotEmpty(t, cloud) diff --git a/doc/source/api.md b/doc/source/api.md index ecb171b..3125378 100644 --- a/doc/source/api.md +++ b/doc/source/api.md @@ -154,6 +154,8 @@ created. If the role exists, it will be updated with the new attributes. If set to `true`, `user_groups` value is ignored. if set to `true`, `user_roles` value is ignored. If set to `true`, `ttl` value is ignored. + if set to `true`, `user_domain_id` value is ignored. + If set to `true`, `user_domain_name` value is ignored. - `ttl` `(string: "1h")` - Specifies TTL value for the dynamically created users as a string duration with time suffix. @@ -179,7 +181,19 @@ created. If the role exists, it will be updated with the new attributes. - `domain_name` `(string: )` - Create a domain-scoped role with given domain name. Mutually exclusive with `domain_id`. -When none of `project_name` or `project_id` is set, created role will have a project scope. +- `user_domain_id` `(string: )` - Specifies domain where user will be created with given domain id. + Mutually exclusive with `user_project_name`. + +- `user_domain_name` `(string: )` - Specifies domain where user will be created with given domain name. + Mutually exclusive with `user_project_id`. + +- `project_domain_id` `(string: )` - Specifies domain for project-scoped role with given domain id. + If one of `project_id` / `project_name` is not set `project_domain_id` value is ignored. + +- `project_domain_name` `(string: )` - Specifies domain for project-scoped role with given domain name. + If one of `project_id` / `project_name` is not set `project_domain_name` value is ignored. + +When one of `project_name` or `project_id` is set, created role will have a project scope. - `extensions` `(list: [])` - A list of strings representing a key/value pair to be used as extensions to the cloud configuration (e.g. `volume_api_version` or endpoint overrides). Format is a key and value diff --git a/openstack/common/gopher_api.go b/openstack/common/gopher_api.go new file mode 100644 index 0000000..af8d003 --- /dev/null +++ b/openstack/common/gopher_api.go @@ -0,0 +1,18 @@ +package common + +import ( + "github.com/gophercloud/gophercloud" + "github.com/gophercloud/gophercloud/openstack/identity/v3/domains" + "github.com/gophercloud/gophercloud/pagination" +) + +func listAvailableURL(client *gophercloud.ServiceClient) string { + return client.ServiceURL("auth", "domains") +} + +func ListAvailable(client *gophercloud.ServiceClient) pagination.Pager { + url := listAvailableURL(client) + return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page { + return domains.DomainPage{LinkedPageBase: pagination.LinkedPageBase{PageResult: r}} + }) +} diff --git a/openstack/fixtures/helpers.go b/openstack/fixtures/helpers.go index 6189f2f..64e9d2d 100644 --- a/openstack/fixtures/helpers.go +++ b/openstack/fixtures/helpers.go @@ -92,11 +92,73 @@ func handleGetToken(t *testing.T, w http.ResponseWriter, r *http.Request, userID w.WriteHeader(http.StatusOK) _, _ = fmt.Fprintf(w, ` { - "token": { - "user": { - "id": "%s" + "token": { + "methods": [ + "password" + ], + "user": { + "domain": { + "id": "28c40f683607401da09214d373785a2d", + "name": "mydomain" + }, + "id": "%s", + "name": "admin", + "password_expires_at": null + }, + "audit_ids": [ + "79uOElBBQguxhPHVLlCiIQ" + ], + "expires_at": "2022-11-19T01:54:23.000000Z", + "issued_at": "2022-11-18T13:54:23.000000Z", + "domain": { + "id": "52af04aec5f84182b06959d2775d2000", + "name": "mydomain" + }, + "roles": [ + { + "id": "7d7d81cdaad4475e96d34817f1632eca", + "name": "reader" + }, + { + "id": "72badea89a5d4d9cb97a4d13e8d8c486", + "name": "member" + }, + { + "id": "60e69bd42e12450b925f763d574d6125", + "name": "admin" + } + ], + "catalog": [ + { + "endpoints": [ + { + "id": "333f811aeff140768a59c9d1d9b43087", + "interface": "internal", + "region_id": "RegionOne", + "url": "http://example.com", + "region": "RegionOne" + }, + { + "id": "96207c76a6154aaaa017214cd0a27810", + "interface": "admin", + "region_id": "RegionOne", + "url": "http://example.com", + "region": "RegionOne" + }, + { + "id": "b3861966f62349d6ae73bf113eacb2cc", + "interface": "public", + "region_id": "RegionOne", + "url": "http://example.com", + "region": "RegionOne" + } + ], + "id": "b2724497bfac49a68578e11fa7c34292", + "type": "identity", + "name": "keystone" + } + ] } - } } `, userID) } @@ -303,18 +365,61 @@ func handleProjectList(t *testing.T, w http.ResponseWriter, r *http.Request, pro `, projectName) } +func handleDomainList(t *testing.T, w http.ResponseWriter, r *http.Request, projectName 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, ` +{ + "domains": [ + { + "id": "test-id", + "name": "%s", + "description": "", + "enabled": true, + "tags": [], + "options": {}, + "links": { + "self": "https://example.com/v3/domains/test-id" + } + }, + { + "id": "default", + "name": "Default", + "description": "The default domain", + "enabled": true, + "tags": [], + "options": {}, + "links": { + "self": "https://example.com/v3/domains/default" + } + } + ], + "links": { + "next": null, + "self": "https://example.com/v3/domains", + "previous": null + } +}`, projectName) +} + type EnabledMocks struct { - TokenPost bool - TokenGet bool - TokenDelete bool - PasswordChange bool - ProjectList bool - UserPost bool - UserPatch bool - UserList bool - UserDelete bool - UserGet bool - GroupList bool + TokenPost bool + TokenGet bool + TokenDelete bool + PasswordChange bool + ProjectList bool + UserPost bool + UserPatch bool + UserList bool + UserDelete bool + UserGet bool + GroupList bool + AvailDomainList bool } func SetupKeystoneMock(t *testing.T, userID, projectName string, enabled EnabledMocks) { @@ -410,4 +515,15 @@ func SetupKeystoneMock(t *testing.T, userID, projectName string, enabled Enabled w.WriteHeader(404) } }) + + th.Mux.HandleFunc("/v3/auth/domains", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + if enabled.AvailDomainList { + handleDomainList(t, w, r, projectName) + } + default: + w.WriteHeader(404) + } + }) } diff --git a/openstack/path_creds.go b/openstack/path_creds.go index 1abc4f4..0727622 100644 --- a/openstack/path_creds.go +++ b/openstack/path_creds.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/gophercloud/gophercloud/openstack/identity/v3/domains" "github.com/opentelekomcloud/vault-plugin-secrets-openstack/openstack/common" "github.com/opentelekomcloud/vault-plugin-secrets-openstack/vars" "net/http" @@ -318,11 +319,11 @@ func (b *backend) userDelete(ctx context.Context, r *logical.Request, _ *framewo } func createUser(client *gophercloud.ServiceClient, username, password string, role *roleEntry) (*users.User, error) { - token := tokens.Get(client, client.Token()) - user, err := token.ExtractUser() + userDomainID, err := getUserDomain(client, role) if err != nil { - return nil, fmt.Errorf("error extracting the user from token: %w", err) + return nil, err } + // TODO: implement situation where userDomainId != currentDomainID projectID := role.ProjectID if projectID == "" && role.ProjectName != "" { @@ -347,7 +348,7 @@ func createUser(client *gophercloud.ServiceClient, username, password string, ro Name: username, DefaultProjectID: projectID, Description: "Vault's temporary user", - DomainID: user.Domain.ID, + DomainID: userDomainID, Password: password, } @@ -368,18 +369,18 @@ func createUser(client *gophercloud.ServiceClient, username, password string, ro ProjectID: projectID, } if err := roles.Assign(client, identityRole.ID, assignOpts).ExtractErr(); err != nil { - return nil, fmt.Errorf("cannot assign a role `%s` to a temporary user: %w", identityRole, err) + return nil, fmt.Errorf("cannot assign a role `%s` to a temporary user: %w", identityRole.Name, err) } } - groupsToAssign, err := filterGroups(client, user.Domain.ID, role.UserGroups) + groupsToAssign, err := filterGroups(client, userDomainID, role.UserGroups) if err != nil { return nil, err } for _, group := range groupsToAssign { if err := users.AddToGroup(client, group.ID, newUser.ID).ExtractErr(); err != nil { - return nil, fmt.Errorf("cannot add a temporary user to a group `%s`: %w", group, err) + return nil, fmt.Errorf("cannot add a temporary user to a group `%s`: %w", group.Name, err) } } @@ -459,6 +460,12 @@ func getScopeFromRole(role *roleEntry) tokens.Scope { scope = tokens.Scope{ ProjectID: role.ProjectID, } + case role.ProjectName != "" && (role.ProjectDomainName != "" || role.ProjectDomainID != ""): + scope = tokens.Scope{ + ProjectName: role.ProjectName, + DomainName: role.ProjectDomainName, + DomainID: role.ProjectDomainID, + } case role.ProjectName != "": scope = tokens.Scope{ ProjectName: role.ProjectName, @@ -531,3 +538,49 @@ func formAuthResponse(role *roleEntry, authResponse *authResponseData) map[strin return auth } + +func getUserDomain(client *gophercloud.ServiceClient, role *roleEntry) (string, error) { + var userDomainID string + var err error + + if role.UserDomainID != "" { + userDomainID = role.UserDomainID + } else if role.UserDomainName != "" { + userDomainID, err = getDomainByName(client, role.UserDomainName) + if err != nil { + return "", err + } + } else { + token := tokens.Get(client, client.Token()) + domain, err := token.ExtractDomain() + if err != nil { + return userDomainID, fmt.Errorf("error extracting the domain from token: %w", err) + } + userDomainID = domain.ID + } + return userDomainID, nil +} + +func getDomainByName(client *gophercloud.ServiceClient, domainName string) (string, error) { + var userDomainID string + err := common.ListAvailable(client).EachPage(func(page pagination.Page) (bool, error) { + availDomains, err := domains.ExtractDomains(page) + if err != nil { + return false, err + } + if len(availDomains) == 0 { + return false, fmt.Errorf("failed to find domain with name: %s", domainName) + } + for _, domain := range availDomains { + if domain.Name == domainName { + userDomainID = domain.ID + return false, nil + } + } + return false, fmt.Errorf("failed to find domain with the name: %s", domainName) + }) + if err != nil { + return "", err + } + return userDomainID, nil +} diff --git a/openstack/path_creds_test.go b/openstack/path_creds_test.go index aec947a..519e3f7 100644 --- a/openstack/path_creds_test.go +++ b/openstack/path_creds_test.go @@ -23,12 +23,13 @@ func TestCredentialsRead_ok(t *testing.T) { userID, _ := uuid.GenerateUUID() projectName := tools.RandomString("p", 5) fixtures.SetupKeystoneMock(t, userID, projectName, fixtures.EnabledMocks{ - TokenPost: true, - TokenGet: true, - ProjectList: true, - TokenDelete: true, - UserPost: true, - UserDelete: true, + TokenPost: true, + TokenGet: true, + ProjectList: true, + TokenDelete: true, + UserPost: true, + UserDelete: true, + AvailDomainList: true, }) testClient := thClient.ServiceClient() @@ -72,6 +73,19 @@ func TestCredentialsRead_ok(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, res.Data) }) + t.Run("user_domain_id_token", func(t *testing.T) { + require.NoError(t, s.Put(context.Background(), cloudEntry)) + + roleName := createSaveUserDomainIDRole(t, s, false, projectName, "token") + + res, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: credsPath(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)) @@ -300,3 +314,20 @@ func createSaveRandomRole(t *testing.T, s logical.Storage, root bool, projectNam return roleName } + +func createSaveUserDomainIDRole(t *testing.T, s logical.Storage, root bool, projectName, sType string) string { + roleName := randomRoleName() + role := map[string]interface{}{ + "name": roleName, + "cloud": testCloudName, + "ttl": time.Hour / time.Second, + "project_name": projectName, + "domain_id": tools.RandomString("d", 5), + "user_domain_name": projectName, + "root": root, + "secret_type": sType, + } + saveRawRole(t, roleName, role, s) + + return roleName +} diff --git a/openstack/path_role.go b/openstack/path_role.go index 4700345..ec96e32 100644 --- a/openstack/path_role.go +++ b/openstack/path_role.go @@ -115,6 +115,22 @@ func (b *backend) pathRole() *framework.Path { Type: framework.TypeNameString, Description: "Specifies a domain name for domain-scoped role.", }, + "user_domain_id": { + Type: framework.TypeLowerCaseString, + Description: "Specifies a domain ID for dynamic user creation.", + }, + "user_domain_name": { + Type: framework.TypeNameString, + Description: "Specifies a domain name for dynamic user creation.", + }, + "project_domain_id": { + Type: framework.TypeLowerCaseString, + Description: "Specifies a domain ID of project.", + }, + "project_domain_name": { + Type: framework.TypeNameString, + Description: "Specifies a domain name of project.", + }, "extensions": { Type: framework.TypeKVPairs, Description: "A list of strings representing a key/value pair to be used as extensions to the cloud " + @@ -157,18 +173,22 @@ const ( ) type roleEntry struct { - Name string `json:"name"` - Cloud string `json:"cloud"` - Root bool `json:"root"` - TTL time.Duration `json:"ttl,omitempty"` - SecretType secretType `json:"secret_type"` - UserGroups []string `json:"user_groups"` - UserRoles []string `json:"user_roles"` - ProjectID string `json:"project_id"` - ProjectName string `json:"project_name"` - DomainID string `json:"domain_id"` - DomainName string `json:"domain_name"` - Extensions map[string]string `json:"extensions"` + Name string `json:"name"` + Cloud string `json:"cloud"` + Root bool `json:"root"` + TTL time.Duration `json:"ttl,omitempty"` + SecretType secretType `json:"secret_type"` + UserGroups []string `json:"user_groups"` + UserRoles []string `json:"user_roles"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + DomainID string `json:"domain_id"` + DomainName string `json:"domain_name"` + UserDomainID string `json:"user_domain_id"` + UserDomainName string `json:"user_domain_name"` + ProjectDomainID string `json:"project_domain_id"` + ProjectDomainName string `json:"project_domain_name"` + Extensions map[string]string `json:"extensions"` } func roleStoragePath(name string) string { @@ -207,17 +227,21 @@ func getRoleByName(ctx context.Context, name string, s logical.Storage) (*roleEn func roleToMap(src *roleEntry) map[string]interface{} { return map[string]interface{}{ - "cloud": src.Cloud, - "root": src.Root, - "ttl": src.TTL, - "secret_type": string(src.SecretType), - "user_groups": src.UserGroups, - "user_roles": src.UserRoles, - "project_id": src.ProjectID, - "project_name": src.ProjectName, - "domain_id": src.DomainID, - "domain_name": src.DomainName, - "extensions": src.Extensions, + "cloud": src.Cloud, + "root": src.Root, + "ttl": src.TTL, + "secret_type": string(src.SecretType), + "user_groups": src.UserGroups, + "user_roles": src.UserRoles, + "project_id": src.ProjectID, + "project_name": src.ProjectName, + "domain_id": src.DomainID, + "domain_name": src.DomainName, + "user_domain_id": src.UserDomainID, + "user_domain_name": src.UserDomainName, + "project_domain_id": src.ProjectDomainID, + "project_domain_name": src.ProjectDomainName, + "extensions": src.Extensions, } } @@ -309,6 +333,22 @@ func (b *backend) pathRoleUpdate(ctx context.Context, req *logical.Request, d *f entry.DomainID = id.(string) } + if name, ok := d.GetOk("user_domain_name"); ok { + entry.UserDomainName = name.(string) + } + + if id, ok := d.GetOk("user_domain_id"); ok { + entry.UserDomainID = id.(string) + } + + if name, ok := d.GetOk("project_domain_name"); ok { + entry.ProjectDomainName = name.(string) + } + + if id, ok := d.GetOk("project_domain_id"); ok { + entry.ProjectDomainID = id.(string) + } + if ext, ok := d.GetOk("extensions"); ok { entry.Extensions = ext.(map[string]string) } @@ -323,13 +363,13 @@ func (b *backend) pathRoleUpdate(ctx context.Context, req *logical.Request, d *f } token := tokens.Get(client, client.Token()) - user, err := token.ExtractUser() + domain, err := token.ExtractDomain() if err != nil { - return nil, fmt.Errorf("error extracting the user from token: %w", err) + return nil, fmt.Errorf("error extracting the domain from token: %w", err) } groupPages, err := groups.List(client, groups.ListOpts{ - DomainID: user.Domain.ID, + DomainID: domain.ID, }).AllPages() if err != nil { return nil, fmt.Errorf("error querying user groups of dynamic role: %w", err) diff --git a/openstack/path_role_test.go b/openstack/path_role_test.go index 90aab0c..b0f5c4b 100644 --- a/openstack/path_role_test.go +++ b/openstack/path_role_test.go @@ -35,23 +35,29 @@ func randomRoleName() string { func expectedRoleData(cloudName string) (*roleEntry, map[string]interface{}) { expTTL := time.Hour expected := &roleEntry{ - Cloud: cloudName, - TTL: expTTL / time.Second, - ProjectName: tools.RandomString("p", 5), - DomainName: tools.RandomString("d", 5), + Cloud: cloudName, + TTL: expTTL / time.Second, + ProjectName: tools.RandomString("p", 5), + DomainName: tools.RandomString("d", 5), + UserDomainName: tools.RandomString("p", 5), + ProjectDomainName: tools.RandomString("d", 5), } 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, - "secret_type": "token", - "user_groups": []string{}, - "user_roles": []string{}, + "cloud": expected.Cloud, + "ttl": expTTL, + "project_id": "", + "project_name": expected.ProjectName, + "domain_id": "", + "domain_name": expected.DomainName, + "user_domain_id": "", + "user_domain_name": expected.UserDomainName, + "project_domain_id": "", + "project_domain_name": expected.ProjectDomainName, + "extensions": map[string]string{}, + "root": false, + "secret_type": "token", + "user_groups": []string{}, + "user_roles": []string{}, } return expected, expectedMap }