Merge pull request #288 from jamiehannaford/os-keystone-users
OpenStack and Rackspace identity users
diff --git a/acceptance/openstack/compute/v2/bootfromvolume_test.go b/acceptance/openstack/compute/v2/bootfromvolume_test.go
index d08abe6..715af26 100644
--- a/acceptance/openstack/compute/v2/bootfromvolume_test.go
+++ b/acceptance/openstack/compute/v2/bootfromvolume_test.go
@@ -5,10 +5,10 @@
import (
"testing"
+ "github.com/rackspace/gophercloud/acceptance/tools"
"github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
th "github.com/rackspace/gophercloud/testhelper"
- "github.com/smashwilson/gophercloud/acceptance/tools"
)
func TestBootFromVolume(t *testing.T) {
diff --git a/acceptance/openstack/identity/v2/extension_test.go b/acceptance/openstack/identity/v2/extension_test.go
index 2b4e062..d1fa1e3 100644
--- a/acceptance/openstack/identity/v2/extension_test.go
+++ b/acceptance/openstack/identity/v2/extension_test.go
@@ -1,4 +1,4 @@
-// +build acceptance
+// +build acceptance identity
package v2
diff --git a/acceptance/openstack/identity/v2/identity_test.go b/acceptance/openstack/identity/v2/identity_test.go
index feae233..96bf1fd 100644
--- a/acceptance/openstack/identity/v2/identity_test.go
+++ b/acceptance/openstack/identity/v2/identity_test.go
@@ -1,4 +1,4 @@
-// +build acceptance
+// +build acceptance identity
package v2
diff --git a/acceptance/openstack/identity/v2/role_test.go b/acceptance/openstack/identity/v2/role_test.go
new file mode 100644
index 0000000..ba243fe
--- /dev/null
+++ b/acceptance/openstack/identity/v2/role_test.go
@@ -0,0 +1,58 @@
+// +build acceptance identity roles
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestRoles(t *testing.T) {
+ client := authenticatedClient(t)
+
+ tenantID := findTenant(t, client)
+ userID := createUser(t, client, tenantID)
+ roleID := listRoles(t, client)
+
+ addUserRole(t, client, tenantID, userID, roleID)
+
+ deleteUserRole(t, client, tenantID, userID, roleID)
+
+ deleteUser(t, client, userID)
+}
+
+func listRoles(t *testing.T, client *gophercloud.ServiceClient) string {
+ var roleID string
+
+ err := roles.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ roleList, err := roles.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ for _, role := range roleList {
+ t.Logf("Listing role: ID [%s] Name [%s]", role.ID, role.Name)
+ roleID = role.ID
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ return roleID
+}
+
+func addUserRole(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID, roleID string) {
+ err := roles.AddUserRole(client, tenantID, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Added role %s to user %s", roleID, userID)
+}
+
+func deleteUserRole(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID, roleID string) {
+ err := roles.DeleteUserRole(client, tenantID, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Removed role %s from user %s", roleID, userID)
+}
diff --git a/acceptance/openstack/identity/v2/tenant_test.go b/acceptance/openstack/identity/v2/tenant_test.go
index 2054598..578fc48 100644
--- a/acceptance/openstack/identity/v2/tenant_test.go
+++ b/acceptance/openstack/identity/v2/tenant_test.go
@@ -1,4 +1,4 @@
-// +build acceptance
+// +build acceptance identity
package v2
diff --git a/acceptance/openstack/identity/v2/token_test.go b/acceptance/openstack/identity/v2/token_test.go
index 0632a48..d903140 100644
--- a/acceptance/openstack/identity/v2/token_test.go
+++ b/acceptance/openstack/identity/v2/token_test.go
@@ -1,4 +1,4 @@
-// +build acceptance
+// +build acceptance identity
package v2
diff --git a/acceptance/openstack/identity/v2/user_test.go b/acceptance/openstack/identity/v2/user_test.go
new file mode 100644
index 0000000..fe73d19
--- /dev/null
+++ b/acceptance/openstack/identity/v2/user_test.go
@@ -0,0 +1,127 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/tenants"
+ "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestUsers(t *testing.T) {
+ client := authenticatedClient(t)
+
+ tenantID := findTenant(t, client)
+
+ userID := createUser(t, client, tenantID)
+
+ listUsers(t, client)
+
+ getUser(t, client, userID)
+
+ updateUser(t, client, userID)
+
+ listUserRoles(t, client, tenantID, userID)
+
+ deleteUser(t, client, userID)
+}
+
+func findTenant(t *testing.T, client *gophercloud.ServiceClient) string {
+ var tenantID string
+ err := tenants.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+ tenantList, err := tenants.ExtractTenants(page)
+ th.AssertNoErr(t, err)
+
+ for _, t := range tenantList {
+ tenantID = t.ID
+ break
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ return tenantID
+}
+
+func createUser(t *testing.T, client *gophercloud.ServiceClient, tenantID string) string {
+ t.Log("Creating user")
+
+ opts := users.CreateOpts{
+ Name: tools.RandomString("user_", 5),
+ Enabled: users.Disabled,
+ TenantID: tenantID,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := users.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created user %s on tenant %s", user.ID, tenantID)
+
+ return user.ID
+}
+
+func listUsers(t *testing.T, client *gophercloud.ServiceClient) {
+ err := users.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ userList, err := users.ExtractUsers(page)
+ th.AssertNoErr(t, err)
+
+ for _, user := range userList {
+ t.Logf("Listing user: ID [%s] Name [%s] Email [%s] Enabled? [%s]",
+ user.ID, user.Name, user.Email, strconv.FormatBool(user.Enabled))
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ _, err := users.Get(client, userID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting user %s", userID)
+}
+
+func updateUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ opts := users.UpdateOpts{Name: tools.RandomString("new_name", 5), Email: "new@foo.com"}
+ user, err := users.Update(client, userID, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated user %s: Name [%s] Email [%s]", userID, user.Name, user.Email)
+}
+
+func listUserRoles(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID string) {
+ count := 0
+ err := users.ListRoles(client, tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+
+ roleList, err := users.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ t.Logf("Listing roles for user %s", userID)
+
+ for _, r := range roleList {
+ t.Logf("- %s (%s)", r.Name, r.ID)
+ }
+
+ return true, nil
+ })
+
+ if count == 0 {
+ t.Logf("No roles for user %s", userID)
+ }
+
+ th.AssertNoErr(t, err)
+}
+
+func deleteUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ res := users.Delete(client, userID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted user %s", userID)
+}
diff --git a/acceptance/rackspace/compute/v2/bootfromvolume_test.go b/acceptance/rackspace/compute/v2/bootfromvolume_test.go
index 010bf42..c8c8e21 100644
--- a/acceptance/rackspace/compute/v2/bootfromvolume_test.go
+++ b/acceptance/rackspace/compute/v2/bootfromvolume_test.go
@@ -5,11 +5,11 @@
import (
"testing"
+ "github.com/rackspace/gophercloud/acceptance/tools"
osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
"github.com/rackspace/gophercloud/rackspace/compute/v2/bootfromvolume"
"github.com/rackspace/gophercloud/rackspace/compute/v2/servers"
th "github.com/rackspace/gophercloud/testhelper"
- "github.com/smashwilson/gophercloud/acceptance/tools"
)
func TestBootFromVolume(t *testing.T) {
diff --git a/acceptance/rackspace/identity/v2/pkg.go b/acceptance/rackspace/identity/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/rackspace/identity/v2/role_test.go b/acceptance/rackspace/identity/v2/role_test.go
new file mode 100644
index 0000000..efaeb75
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/role_test.go
@@ -0,0 +1,59 @@
+// +build acceptance identity roles
+
+package v2
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/identity/v2/roles"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestRoles(t *testing.T) {
+ client := authenticatedClient(t)
+
+ userID := createUser(t, client)
+ roleID := listRoles(t, client)
+
+ addUserRole(t, client, userID, roleID)
+
+ deleteUserRole(t, client, userID, roleID)
+
+ deleteUser(t, client, userID)
+}
+
+func listRoles(t *testing.T, client *gophercloud.ServiceClient) string {
+ var roleID string
+
+ err := roles.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ roleList, err := os.ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ for _, role := range roleList {
+ t.Logf("Listing role: ID [%s] Name [%s]", role.ID, role.Name)
+ roleID = role.ID
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+
+ return roleID
+}
+
+func addUserRole(t *testing.T, client *gophercloud.ServiceClient, userID, roleID string) {
+ err := roles.AddUserRole(client, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Added role %s to user %s", roleID, userID)
+}
+
+func deleteUserRole(t *testing.T, client *gophercloud.ServiceClient, userID, roleID string) {
+ err := roles.DeleteUserRole(client, userID, roleID).ExtractErr()
+ th.AssertNoErr(t, err)
+ t.Logf("Removed role %s from user %s", roleID, userID)
+}
diff --git a/acceptance/rackspace/identity/v2/user_test.go b/acceptance/rackspace/identity/v2/user_test.go
new file mode 100644
index 0000000..d3234e8
--- /dev/null
+++ b/acceptance/rackspace/identity/v2/user_test.go
@@ -0,0 +1,80 @@
+// +build acceptance identity
+
+package v2
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/acceptance/tools"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+ "github.com/rackspace/gophercloud/rackspace/identity/v2/users"
+ th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestUsers(t *testing.T) {
+ client := authenticatedClient(t)
+
+ userID := createUser(t, client)
+
+ listUsers(t, client)
+
+ getUser(t, client, userID)
+
+ updateUser(t, client, userID)
+
+ deleteUser(t, client, userID)
+}
+
+func createUser(t *testing.T, client *gophercloud.ServiceClient) string {
+ t.Log("Creating user")
+
+ opts := users.CreateOpts{
+ Username: tools.RandomString("user_", 5),
+ Enabled: os.Disabled,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := users.Create(client, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Created user %s", user.ID)
+
+ return user.ID
+}
+
+func listUsers(t *testing.T, client *gophercloud.ServiceClient) {
+ err := users.List(client).EachPage(func(page pagination.Page) (bool, error) {
+ userList, err := os.ExtractUsers(page)
+ th.AssertNoErr(t, err)
+
+ for _, user := range userList {
+ t.Logf("Listing user: ID [%s] Username [%s] Email [%s] Enabled? [%s]",
+ user.ID, user.Username, user.Email, strconv.FormatBool(user.Enabled))
+ }
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
+
+func getUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ _, err := users.Get(client, userID).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Getting user %s", userID)
+}
+
+func updateUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ opts := users.UpdateOpts{Username: tools.RandomString("new_name", 5), Email: "new@foo.com"}
+ user, err := users.Update(client, userID, opts).Extract()
+ th.AssertNoErr(t, err)
+ t.Logf("Updated user %s: Username [%s] Email [%s]", userID, user.Username, user.Email)
+}
+
+func deleteUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+ res := users.Delete(client, userID)
+ th.AssertNoErr(t, res.Err)
+ t.Logf("Deleted user %s", userID)
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/docs.go b/openstack/identity/v2/extensions/admin/roles/docs.go
new file mode 100644
index 0000000..8954178
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/docs.go
@@ -0,0 +1,16 @@
+// Package roles provides functionality to interact with and control roles on
+// the API.
+//
+// A role represents a personality that a user can assume when performing a
+// specific set of operations. If a role includes a set of rights and
+// privileges, a user assuming that role inherits those rights and privileges.
+//
+// When a token is generated, the list of roles that user can assume is returned
+// back to them. Services that are being called by that user determine how they
+// interpret the set of roles a user has and to which operations or resources
+// each role grants access.
+//
+// It is up to individual services such as Compute or Image to assign meaning
+// to these roles. As far as the Identity service is concerned, a role is an
+// arbitrary name assigned by the user.
+package roles
diff --git a/openstack/identity/v2/extensions/admin/roles/fixtures.go b/openstack/identity/v2/extensions/admin/roles/fixtures.go
new file mode 100644
index 0000000..8256f0f
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/fixtures.go
@@ -0,0 +1,48 @@
+package roles
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/OS-KSADM/roles", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "roles": [
+ {
+ "id": "123",
+ "name": "compute:admin",
+ "description": "Nova Administrator"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func MockAddUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/tenants/{tenant_id}/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+func MockDeleteUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/tenants/{tenant_id}/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/requests.go b/openstack/identity/v2/extensions/admin/roles/requests.go
new file mode 100644
index 0000000..152031a
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests.go
@@ -0,0 +1,44 @@
+package roles
+
+import (
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List is the operation responsible for listing all available global roles
+// that a user can adopt.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return RolePage{pagination.SinglePageBase(r)}
+ }
+ return pagination.NewPager(client, rootURL(client), createPage)
+}
+
+// AddUserRole is the operation responsible for assigning a particular role to
+// a user. This is confined to the scope of the user's tenant - so the tenant
+// ID is a required argument.
+func AddUserRole(client *gophercloud.ServiceClient, tenantID, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+
+ _, result.Err = perigee.Request("PUT", userRoleURL(client, tenantID, userID, roleID), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200, 201},
+ })
+
+ return result
+}
+
+// DeleteUserRole is the operation responsible for deleting a particular role
+// from a user. This is confined to the scope of the user's tenant - so the
+// tenant ID is a required argument.
+func DeleteUserRole(client *gophercloud.ServiceClient, tenantID, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+
+ _, result.Err = perigee.Request("DELETE", userRoleURL(client, tenantID, userID, roleID), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{204},
+ })
+
+ return result
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/requests_test.go b/openstack/identity/v2/extensions/admin/roles/requests_test.go
new file mode 100644
index 0000000..7bfeea4
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests_test.go
@@ -0,0 +1,64 @@
+package roles
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListRoleResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractRoles(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []Role{
+ Role{
+ ID: "123",
+ Name: "compute:admin",
+ Description: "Nova Administrator",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestAddUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockAddUserRoleResponse(t)
+
+ err := AddUserRole(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestDeleteUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteUserRoleResponse(t)
+
+ err := DeleteUserRole(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/results.go b/openstack/identity/v2/extensions/admin/roles/results.go
new file mode 100644
index 0000000..ebb3aa5
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/results.go
@@ -0,0 +1,53 @@
+package roles
+
+import (
+ "github.com/mitchellh/mapstructure"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// Role represents an API role resource.
+type Role struct {
+ // The unique ID for the role.
+ ID string
+
+ // The human-readable name of the role.
+ Name string
+
+ // The description of the role.
+ Description string
+
+ // The associated service for this role.
+ ServiceID string
+}
+
+// RolePage is a single page of a user Role collection.
+type RolePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page RolePage) IsEmpty() (bool, error) {
+ users, err := ExtractRoles(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractRoles returns a slice of roles contained in a single page of results.
+func ExtractRoles(page pagination.Page) ([]Role, error) {
+ casted := page.(RolePage).Body
+ var response struct {
+ Roles []Role `mapstructure:"roles"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Roles, err
+}
+
+// UserRoleResult represents the result of either an AddUserRole or
+// a DeleteUserRole operation.
+type UserRoleResult struct {
+ gophercloud.ErrResult
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/urls.go b/openstack/identity/v2/extensions/admin/roles/urls.go
new file mode 100644
index 0000000..61b3155
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/urls.go
@@ -0,0 +1,21 @@
+package roles
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ ExtPath = "OS-KSADM"
+ RolePath = "roles"
+ UserPath = "users"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(ExtPath, RolePath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(ExtPath, RolePath)
+}
+
+func userRoleURL(c *gophercloud.ServiceClient, tenantID, userID, roleID string) string {
+ return c.ServiceURL("tenants", tenantID, UserPath, userID, RolePath, ExtPath, roleID)
+}
diff --git a/openstack/identity/v2/users/doc.go b/openstack/identity/v2/users/doc.go
new file mode 100644
index 0000000..82abcb9
--- /dev/null
+++ b/openstack/identity/v2/users/doc.go
@@ -0,0 +1 @@
+package users
diff --git a/openstack/identity/v2/users/fixtures.go b/openstack/identity/v2/users/fixtures.go
new file mode 100644
index 0000000..8941868
--- /dev/null
+++ b/openstack/identity/v2/users/fixtures.go
@@ -0,0 +1,163 @@
+package users
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "users":[
+ {
+ "id": "u1000",
+ "name": "John Smith",
+ "username": "jqsmith",
+ "email": "john.smith@example.org",
+ "enabled": true,
+ "tenant_id": "12345"
+ },
+ {
+ "id": "u1001",
+ "name": "Jane Smith",
+ "username": "jqsmith",
+ "email": "jane.smith@example.org",
+ "enabled": true,
+ "tenant_id": "12345"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "user": {
+ "name": "new_user",
+ "tenant_id": "12345",
+ "enabled": false,
+ "email": "new_user@foo.com"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "name": "new_user",
+ "tenant_id": "12345",
+ "enabled": false,
+ "email": "new_user@foo.com",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+ }
+}
+`)
+ })
+}
+
+func mockGetUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/new_user", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "name": "new_user",
+ "tenant_id": "12345",
+ "enabled": false,
+ "email": "new_user@foo.com",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+ }
+}
+`)
+ })
+}
+
+func mockUpdateUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "user": {
+ "name": "new_name",
+ "enabled": true,
+ "email": "new_email@foo.com"
+ }
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "name": "new_name",
+ "tenant_id": "12345",
+ "enabled": true,
+ "email": "new_email@foo.com",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+ }
+}
+`)
+ })
+}
+
+func mockDeleteUserResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+func mockListRolesResponse(t *testing.T) {
+ th.Mux.HandleFunc("/tenants/1d8b6120dcc640fda4fc9194ffc80273/users/c39e3de9be2d4c779f1dfd6abacc176d/roles", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "roles": [
+ {
+ "id": "9fe2ff9ee4384b1894a90878d3e92bab",
+ "name": "foo_role"
+ },
+ {
+ "id": "1ea3d56793574b668e85960fbf651e13",
+ "name": "admin"
+ }
+ ]
+}
+ `)
+ })
+}
diff --git a/openstack/identity/v2/users/requests.go b/openstack/identity/v2/users/requests.go
new file mode 100644
index 0000000..e6bb591
--- /dev/null
+++ b/openstack/identity/v2/users/requests.go
@@ -0,0 +1,178 @@
+package users
+
+import (
+ "errors"
+
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return UserPage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, rootURL(client), createPage)
+}
+
+// EnabledState represents whether the user is enabled or not.
+type EnabledState *bool
+
+// Useful variables to use when creating or updating users.
+var (
+ iTrue = true
+ iFalse = false
+
+ Enabled EnabledState = &iTrue
+ Disabled EnabledState = &iFalse
+)
+
+type commonOpts struct {
+ // Either a name or username is required. When provided, the value must be
+ // unique or a 409 conflict error will be returned. If you provide a name but
+ // omit a username, the latter will be set to the former; and vice versa.
+ Name, Username string
+
+ // The ID of the tenant to which you want to assign this user.
+ TenantID string
+
+ // Indicates whether this user is enabled or not.
+ Enabled EnabledState
+
+ // The email address of this user.
+ Email string
+}
+
+// CreateOpts represents the options needed when creating new users.
+type CreateOpts commonOpts
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+type CreateOptsBuilder interface {
+ ToUserCreateMap() (map[string]interface{}, error)
+}
+
+// ToUserCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+ m := make(map[string]interface{})
+
+ if opts.Name == "" && opts.Username == "" {
+ return m, errors.New("Either a Name or Username must be provided")
+ }
+
+ if opts.Name != "" {
+ m["name"] = opts.Name
+ }
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = &opts.Enabled
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+ if opts.TenantID != "" {
+ m["tenant_id"] = opts.TenantID
+ }
+
+ return map[string]interface{}{"user": m}, nil
+}
+
+// Create is the operation responsible for creating new users.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult {
+ var res CreateResult
+
+ reqBody, err := opts.ToUserCreateMap()
+ if err != nil {
+ res.Err = err
+ return res
+ }
+
+ _, res.Err = perigee.Request("POST", rootURL(client), perigee.Options{
+ Results: &res.Body,
+ ReqBody: reqBody,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200, 201},
+ })
+
+ return res
+}
+
+// Get requests details on a single user, either by ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ var result GetResult
+
+ _, result.Err = perigee.Request("GET", ResourceURL(client, id), perigee.Options{
+ Results: &result.Body,
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// UpdateOptsBuilder allows extentions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToUserUpdateMap() map[string]interface{}
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts commonOpts
+
+// ToUserUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToUserUpdateMap() map[string]interface{} {
+ m := make(map[string]interface{})
+
+ if opts.Name != "" {
+ m["name"] = opts.Name
+ }
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = &opts.Enabled
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+ if opts.TenantID != "" {
+ m["tenant_id"] = opts.TenantID
+ }
+
+ return map[string]interface{}{"user": m}
+}
+
+// Update is the operation responsible for updating exist users by their UUID.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var result UpdateResult
+
+ _, result.Err = perigee.Request("PUT", ResourceURL(client, id), perigee.Options{
+ Results: &result.Body,
+ ReqBody: opts.ToUserUpdateMap(),
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Delete is the operation responsible for permanently deleting an API user.
+func Delete(client *gophercloud.ServiceClient, id string) DeleteResult {
+ var result DeleteResult
+
+ _, result.Err = perigee.Request("DELETE", ResourceURL(client, id), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{204},
+ })
+
+ return result
+}
+
+func ListRoles(client *gophercloud.ServiceClient, tenantID, userID string) pagination.Pager {
+ createPage := func(r pagination.PageResult) pagination.Page {
+ return RolePage{pagination.SinglePageBase(r)}
+ }
+
+ return pagination.NewPager(client, listRolesURL(client, tenantID, userID), createPage)
+}
diff --git a/openstack/identity/v2/users/requests_test.go b/openstack/identity/v2/users/requests_test.go
new file mode 100644
index 0000000..04f8371
--- /dev/null
+++ b/openstack/identity/v2/users/requests_test.go
@@ -0,0 +1,165 @@
+package users
+
+import (
+ "testing"
+
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListUserResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := ExtractUsers(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []User{
+ User{
+ ID: "u1000",
+ Name: "John Smith",
+ Username: "jqsmith",
+ Email: "john.smith@example.org",
+ Enabled: true,
+ TenantID: "12345",
+ },
+ User{
+ ID: "u1001",
+ Name: "Jane Smith",
+ Username: "jqsmith",
+ Email: "jane.smith@example.org",
+ Enabled: true,
+ TenantID: "12345",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateUserResponse(t)
+
+ opts := CreateOpts{
+ Name: "new_user",
+ TenantID: "12345",
+ Enabled: Disabled,
+ Email: "new_user@foo.com",
+ }
+
+ user, err := Create(client.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "new_user",
+ ID: "c39e3de9be2d4c779f1dfd6abacc176d",
+ Email: "new_user@foo.com",
+ Enabled: false,
+ TenantID: "12345",
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestGetUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetUserResponse(t)
+
+ user, err := Get(client.ServiceClient(), "new_user").Extract()
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "new_user",
+ ID: "c39e3de9be2d4c779f1dfd6abacc176d",
+ Email: "new_user@foo.com",
+ Enabled: false,
+ TenantID: "12345",
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestUpdateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateUserResponse(t)
+
+ id := "c39e3de9be2d4c779f1dfd6abacc176d"
+ opts := UpdateOpts{
+ Name: "new_name",
+ Enabled: Enabled,
+ Email: "new_email@foo.com",
+ }
+
+ user, err := Update(client.ServiceClient(), id, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ expected := &User{
+ Name: "new_name",
+ ID: id,
+ Email: "new_email@foo.com",
+ Enabled: true,
+ TenantID: "12345",
+ }
+
+ th.AssertDeepEquals(t, expected, user)
+}
+
+func TestDeleteUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteUserResponse(t)
+
+ res := Delete(client.ServiceClient(), "c39e3de9be2d4c779f1dfd6abacc176d")
+ th.AssertNoErr(t, res.Err)
+}
+
+func TestListingUserRoles(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListRolesResponse(t)
+
+ tenantID := "1d8b6120dcc640fda4fc9194ffc80273"
+ userID := "c39e3de9be2d4c779f1dfd6abacc176d"
+
+ err := ListRoles(client.ServiceClient(), tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+ actual, err := ExtractRoles(page)
+ th.AssertNoErr(t, err)
+
+ expected := []Role{
+ Role{ID: "9fe2ff9ee4384b1894a90878d3e92bab", Name: "foo_role"},
+ Role{ID: "1ea3d56793574b668e85960fbf651e13", Name: "admin"},
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/users/results.go b/openstack/identity/v2/users/results.go
new file mode 100644
index 0000000..f531d5d
--- /dev/null
+++ b/openstack/identity/v2/users/results.go
@@ -0,0 +1,128 @@
+package users
+
+import (
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// User represents a user resource that exists on the API.
+type User struct {
+ // The UUID for this user.
+ ID string
+
+ // The human name for this user.
+ Name string
+
+ // The username for this user.
+ Username string
+
+ // Indicates whether the user is enabled (true) or disabled (false).
+ Enabled bool
+
+ // The email address for this user.
+ Email string
+
+ // The ID of the tenant to which this user belongs.
+ TenantID string `mapstructure:"tenant_id"`
+}
+
+// Role assigns specific responsibilities to users, allowing them to accomplish
+// certain API operations whilst scoped to a service.
+type Role struct {
+ // UUID of the role
+ ID string
+
+ // Name of the role
+ Name string
+}
+
+// UserPage is a single page of a User collection.
+type UserPage struct {
+ pagination.SinglePageBase
+}
+
+// RolePage is a single page of a user Role collection.
+type RolePage struct {
+ pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page UserPage) IsEmpty() (bool, error) {
+ users, err := ExtractUsers(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractUsers returns a slice of Tenants contained in a single page of results.
+func ExtractUsers(page pagination.Page) ([]User, error) {
+ casted := page.(UserPage).Body
+ var response struct {
+ Users []User `mapstructure:"users"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Users, err
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (page RolePage) IsEmpty() (bool, error) {
+ users, err := ExtractRoles(page)
+ if err != nil {
+ return false, err
+ }
+ return len(users) == 0, nil
+}
+
+// ExtractRoles returns a slice of Roles contained in a single page of results.
+func ExtractRoles(page pagination.Page) ([]Role, error) {
+ casted := page.(RolePage).Body
+ var response struct {
+ Roles []Role `mapstructure:"roles"`
+ }
+
+ err := mapstructure.Decode(casted, &response)
+ return response.Roles, err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as a User, if possible.
+func (r commonResult) Extract() (*User, error) {
+ if r.Err != nil {
+ return nil, r.Err
+ }
+
+ var response struct {
+ User User `mapstructure:"user"`
+ }
+
+ err := mapstructure.Decode(r.Body, &response)
+
+ return &response.User, err
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+ commonResult
+}
+
+// DeleteResult represents the result of a Delete operation
+type DeleteResult struct {
+ commonResult
+}
diff --git a/openstack/identity/v2/users/urls.go b/openstack/identity/v2/users/urls.go
new file mode 100644
index 0000000..7ec4385
--- /dev/null
+++ b/openstack/identity/v2/users/urls.go
@@ -0,0 +1,21 @@
+package users
+
+import "github.com/rackspace/gophercloud"
+
+const (
+ tenantPath = "tenants"
+ userPath = "users"
+ rolePath = "roles"
+)
+
+func ResourceURL(c *gophercloud.ServiceClient, id string) string {
+ return c.ServiceURL(userPath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL(userPath)
+}
+
+func listRolesURL(c *gophercloud.ServiceClient, tenantID, userID string) string {
+ return c.ServiceURL(tenantPath, tenantID, userPath, userID, rolePath)
+}
diff --git a/rackspace/identity/v2/roles/delegate.go b/rackspace/identity/v2/roles/delegate.go
new file mode 100644
index 0000000..a6c01e4
--- /dev/null
+++ b/rackspace/identity/v2/roles/delegate.go
@@ -0,0 +1,53 @@
+package roles
+
+import (
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+ "github.com/rackspace/gophercloud/pagination"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+)
+
+// List is the operation responsible for listing all available global roles
+// that a user can adopt.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+// AddUserRole is the operation responsible for assigning a particular role to
+// a user. This is confined to the scope of the user's tenant - so the tenant
+// ID is a required argument.
+func AddUserRole(client *gophercloud.ServiceClient, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+
+ _, result.Err = perigee.Request("PUT", userRoleURL(client, userID, roleID), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200, 201},
+ })
+
+ return result
+}
+
+// DeleteUserRole is the operation responsible for deleting a particular role
+// from a user. This is confined to the scope of the user's tenant - so the
+// tenant ID is a required argument.
+func DeleteUserRole(client *gophercloud.ServiceClient, userID, roleID string) UserRoleResult {
+ var result UserRoleResult
+
+ _, result.Err = perigee.Request("DELETE", userRoleURL(client, userID, roleID), perigee.Options{
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{204},
+ })
+
+ return result
+}
+
+// UserRoleResult represents the result of either an AddUserRole or
+// a DeleteUserRole operation.
+type UserRoleResult struct {
+ gophercloud.ErrResult
+}
+
+func userRoleURL(c *gophercloud.ServiceClient, userID, roleID string) string {
+ return c.ServiceURL(os.UserPath, userID, os.RolePath, os.ExtPath, roleID)
+}
diff --git a/rackspace/identity/v2/roles/delegate_test.go b/rackspace/identity/v2/roles/delegate_test.go
new file mode 100644
index 0000000..fcee97d
--- /dev/null
+++ b/rackspace/identity/v2/roles/delegate_test.go
@@ -0,0 +1,66 @@
+package roles
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/extensions/admin/roles"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockListRoleResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ actual, err := os.ExtractRoles(page)
+ if err != nil {
+ t.Errorf("Failed to extract users: %v", err)
+ return false, err
+ }
+
+ expected := []os.Role{
+ os.Role{
+ ID: "123",
+ Name: "compute:admin",
+ Description: "Nova Administrator",
+ ServiceID: "cke5372ebabeeabb70a0e702a4626977x4406e5",
+ },
+ }
+
+ th.CheckDeepEquals(t, expected, actual)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestAddUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockAddUserRoleResponse(t)
+
+ err := AddUserRole(client.ServiceClient(), "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
+
+func TestDeleteUserRole(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ MockDeleteUserRoleResponse(t)
+
+ err := DeleteUserRole(client.ServiceClient(), "{user_id}", "{role_id}").ExtractErr()
+
+ th.AssertNoErr(t, err)
+}
diff --git a/rackspace/identity/v2/roles/fixtures.go b/rackspace/identity/v2/roles/fixtures.go
new file mode 100644
index 0000000..5f22d0f
--- /dev/null
+++ b/rackspace/identity/v2/roles/fixtures.go
@@ -0,0 +1,49 @@
+package roles
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func MockListRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/OS-KSADM/roles", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "roles": [
+ {
+ "id": "123",
+ "name": "compute:admin",
+ "description": "Nova Administrator",
+ "serviceId": "cke5372ebabeeabb70a0e702a4626977x4406e5"
+ }
+ ]
+}
+ `)
+ })
+}
+
+func MockAddUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PUT")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusCreated)
+ })
+}
+
+func MockDeleteUserRoleResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/rackspace/identity/v2/users/delegate.go b/rackspace/identity/v2/users/delegate.go
new file mode 100644
index 0000000..07c7c97
--- /dev/null
+++ b/rackspace/identity/v2/users/delegate.go
@@ -0,0 +1,130 @@
+package users
+
+import (
+ "errors"
+
+ "github.com/racker/perigee"
+ "github.com/rackspace/gophercloud"
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+)
+
+// List returns a pager that allows traversal over a collection of users.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+ return os.List(client)
+}
+
+type commonOpts struct {
+ // Required. The username to assign to the user. When provided, the username
+ // must:
+ // - start with an alphabetical (A-Za-z) character
+ // - have a minimum length of 1 character
+ //
+ // The username may contain upper and lowercase characters, as well as any of
+ // the following special character: . - @ _
+ Username string
+
+ // Required. Email address for the user account.
+ Email string
+
+ // Required. Indicates whether the user can authenticate after the user
+ // account is created. If no value is specified, the default value is true.
+ Enabled os.EnabledState
+
+ // Optional. The password to assign to the user. If provided, the password
+ // must:
+ // - start with an alphabetical (A-Za-z) character
+ // - have a minimum length of 8 characters
+ // - contain at least one uppercase character, one lowercase character, and
+ // one numeric character.
+ //
+ // The password may contain any of the following special characters: . - @ _
+ Password string
+}
+
+// CreateOpts represents the options needed when creating new users.
+type CreateOpts commonOpts
+
+// ToUserCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+ m := make(map[string]interface{})
+
+ if opts.Username == "" {
+ return m, errors.New("Username is a required field")
+ }
+ if opts.Enabled == nil {
+ return m, errors.New("Enabled is a required field")
+ }
+ if opts.Email == "" {
+ return m, errors.New("Email is a required field")
+ }
+
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = opts.Enabled
+ }
+ if opts.Password != "" {
+ m["OS-KSADM:password"] = opts.Password
+ }
+
+ return map[string]interface{}{"user": m}, nil
+}
+
+// Create is the operation responsible for creating new users.
+func Create(client *gophercloud.ServiceClient, opts os.CreateOptsBuilder) CreateResult {
+ return CreateResult{os.Create(client, opts)}
+}
+
+// Get requests details on a single user, either by ID.
+func Get(client *gophercloud.ServiceClient, id string) GetResult {
+ return GetResult{os.Get(client, id)}
+}
+
+// UpdateOptsBuilder allows extentions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+ ToUserUpdateMap() map[string]interface{}
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts commonOpts
+
+// ToUserUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToUserUpdateMap() map[string]interface{} {
+ m := make(map[string]interface{})
+
+ if opts.Username != "" {
+ m["username"] = opts.Username
+ }
+ if opts.Enabled != nil {
+ m["enabled"] = &opts.Enabled
+ }
+ if opts.Email != "" {
+ m["email"] = opts.Email
+ }
+
+ return map[string]interface{}{"user": m}
+}
+
+// Update is the operation responsible for updating exist users by their UUID.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult {
+ var result UpdateResult
+
+ _, result.Err = perigee.Request("POST", os.ResourceURL(client, id), perigee.Options{
+ Results: &result.Body,
+ ReqBody: opts.ToUserUpdateMap(),
+ MoreHeaders: client.AuthenticatedHeaders(),
+ OkCodes: []int{200},
+ })
+
+ return result
+}
+
+// Delete is the operation responsible for permanently deleting an API user.
+func Delete(client *gophercloud.ServiceClient, id string) os.DeleteResult {
+ return os.Delete(client, id)
+}
diff --git a/rackspace/identity/v2/users/delegate_test.go b/rackspace/identity/v2/users/delegate_test.go
new file mode 100644
index 0000000..616d64c
--- /dev/null
+++ b/rackspace/identity/v2/users/delegate_test.go
@@ -0,0 +1,99 @@
+package users
+
+import (
+ "testing"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+ "github.com/rackspace/gophercloud/pagination"
+ th "github.com/rackspace/gophercloud/testhelper"
+ "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockListResponse(t)
+
+ count := 0
+
+ err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+ count++
+ users, err := os.ExtractUsers(page)
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, "u1000", users[0].ID)
+ th.AssertEquals(t, "u1001", users[1].ID)
+
+ return true, nil
+ })
+
+ th.AssertNoErr(t, err)
+ th.AssertEquals(t, 1, count)
+}
+
+func TestCreateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockCreateUser(t)
+
+ opts := CreateOpts{
+ Username: "new_user",
+ Enabled: os.Disabled,
+ Email: "new_user@foo.com",
+ Password: "foo",
+ }
+
+ user, err := Create(client.ServiceClient(), opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, "123456", user.ID)
+ th.AssertEquals(t, "5830280", user.DomainID)
+ th.AssertEquals(t, "DFW", user.DefaultRegion)
+}
+
+func TestGetUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockGetUser(t)
+
+ user, err := Get(client.ServiceClient(), "new_user").Extract()
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, true, user.Enabled)
+ th.AssertEquals(t, true, user.MultiFactorEnabled)
+}
+
+func TestUpdateUser(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockUpdateUser(t)
+
+ id := "c39e3de9be2d4c779f1dfd6abacc176d"
+
+ opts := UpdateOpts{
+ Enabled: os.Enabled,
+ Email: "new_email@foo.com",
+ }
+
+ user, err := Update(client.ServiceClient(), id, opts).Extract()
+
+ th.AssertNoErr(t, err)
+
+ th.AssertEquals(t, true, user.Enabled)
+ th.AssertEquals(t, "new_email@foo.com", user.Email)
+}
+
+func TestDeleteServer(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ mockDeleteUser(t)
+
+ res := Delete(client.ServiceClient(), "c39e3de9be2d4c779f1dfd6abacc176d")
+ th.AssertNoErr(t, res.Err)
+}
diff --git a/rackspace/identity/v2/users/fixtures.go b/rackspace/identity/v2/users/fixtures.go
new file mode 100644
index 0000000..e843966
--- /dev/null
+++ b/rackspace/identity/v2/users/fixtures.go
@@ -0,0 +1,138 @@
+package users
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ th "github.com/rackspace/gophercloud/testhelper"
+ fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func mockListResponse(t *testing.T) {
+ th.Mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "users":[
+ {
+ "id": "u1000",
+ "username": "jqsmith",
+ "email": "john.smith@example.org",
+ "enabled": true
+ },
+ {
+ "id": "u1001",
+ "username": "jqsmith",
+ "email": "jane.smith@example.org",
+ "enabled": true
+ }
+ ]
+}
+ `)
+ })
+}
+
+func mockCreateUser(t *testing.T) {
+ th.Mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "user": {
+ "username": "new_user",
+ "enabled": false,
+ "email": "new_user@foo.com",
+ "OS-KSADM:password": "foo"
+ }
+}
+ `)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "RAX-AUTH:defaultRegion": "DFW",
+ "RAX-AUTH:domainId": "5830280",
+ "id": "123456",
+ "username": "new_user",
+ "email": "new_user@foo.com",
+ "enabled": false
+ }
+}
+`)
+ })
+}
+
+func mockGetUser(t *testing.T) {
+ th.Mux.HandleFunc("/users/new_user", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "RAX-AUTH:defaultRegion": "DFW",
+ "RAX-AUTH:domainId": "5830280",
+ "RAX-AUTH:multiFactorEnabled": "true",
+ "id": "c39e3de9be2d4c779f1dfd6abacc176d",
+ "username": "jqsmith",
+ "email": "john.smith@example.org",
+ "enabled": true
+ }
+}
+`)
+ })
+}
+
+func mockUpdateUser(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+ th.TestJSONRequest(t, r, `
+{
+ "user": {
+ "email": "new_email@foo.com",
+ "enabled": true
+ }
+}
+`)
+
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ fmt.Fprintf(w, `
+{
+ "user": {
+ "RAX-AUTH:defaultRegion": "DFW",
+ "RAX-AUTH:domainId": "5830280",
+ "RAX-AUTH:multiFactorEnabled": "true",
+ "id": "123456",
+ "username": "jqsmith",
+ "email": "new_email@foo.com",
+ "enabled": true
+ }
+}
+`)
+ })
+}
+
+func mockDeleteUser(t *testing.T) {
+ th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/rackspace/identity/v2/users/results.go b/rackspace/identity/v2/users/results.go
new file mode 100644
index 0000000..670060e
--- /dev/null
+++ b/rackspace/identity/v2/users/results.go
@@ -0,0 +1,99 @@
+package users
+
+import (
+ "strconv"
+
+ os "github.com/rackspace/gophercloud/openstack/identity/v2/users"
+
+ "github.com/mitchellh/mapstructure"
+)
+
+// User represents a user resource that exists on the API.
+type User struct {
+ // The UUID for this user.
+ ID string
+
+ // The human name for this user.
+ Name string
+
+ // The username for this user.
+ Username string
+
+ // Indicates whether the user is enabled (true) or disabled (false).
+ Enabled bool
+
+ // The email address for this user.
+ Email string
+
+ // The ID of the tenant to which this user belongs.
+ TenantID string `mapstructure:"tenant_id"`
+
+ // Specifies the default region for the user account. This value is inherited
+ // from the user administrator when the account is created.
+ DefaultRegion string `mapstructure:"RAX-AUTH:defaultRegion"`
+
+ // Identifies the domain that contains the user account. This value is
+ // inherited from the user administrator when the account is created.
+ DomainID string `mapstructure:"RAX-AUTH:domainId"`
+
+ // The password value that the user needs for authentication. If the Add user
+ // request included a password value, this attribute is not included in the
+ // response.
+ Password string `mapstructure:"OS-KSADM:password"`
+
+ // Indicates whether the user has enabled multi-factor authentication.
+ MultiFactorEnabled bool `mapstructure:"RAX-AUTH:multiFactorEnabled"`
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+ os.CreateResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+ os.GetResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+ os.UpdateResult
+}
+
+func commonExtract(resp interface{}, err error) (*User, error) {
+ if err != nil {
+ return nil, err
+ }
+
+ var respStruct struct {
+ User *User `json:"user"`
+ }
+
+ // Since the API returns a string instead of a bool, we need to hack the JSON
+ json := resp.(map[string]interface{})
+ user := json["user"].(map[string]interface{})
+ if s, ok := user["RAX-AUTH:multiFactorEnabled"].(string); ok && s != "" {
+ if b, err := strconv.ParseBool(s); err == nil {
+ user["RAX-AUTH:multiFactorEnabled"] = b
+ }
+ }
+
+ err = mapstructure.Decode(json, &respStruct)
+
+ return respStruct.User, err
+}
+
+// Extract will get the Snapshot object out of the GetResult object.
+func (r GetResult) Extract() (*User, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Snapshot object out of the CreateResult object.
+func (r CreateResult) Extract() (*User, error) {
+ return commonExtract(r.Body, r.Err)
+}
+
+// Extract will get the Snapshot object out of the UpdateResult object.
+func (r UpdateResult) Extract() (*User, error) {
+ return commonExtract(r.Body, r.Err)
+}