OpenStack DB users
diff --git a/openstack/db/v1/users/doc.go b/openstack/db/v1/users/doc.go
new file mode 100644
index 0000000..82abcb9
--- /dev/null
+++ b/openstack/db/v1/users/doc.go
@@ -0,0 +1 @@
+package users
diff --git a/openstack/db/v1/users/fixtures.go b/openstack/db/v1/users/fixtures.go
new file mode 100644
index 0000000..6bada41
--- /dev/null
+++ b/openstack/db/v1/users/fixtures.go
@@ -0,0 +1,91 @@
+package users
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/rackspace/gophercloud/testhelper"
+	fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func HandleCreateUserSuccessfully(t *testing.T, instanceID string) {
+	th.Mux.HandleFunc("/instances/"+instanceID+"/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, `
+{
+  "users": [
+    {
+      "databases": [
+        {
+          "name": "databaseA"
+        }
+      ],
+      "name": "dbuser3",
+      "password": "secretsecret"
+    },
+    {
+      "databases": [
+        {
+          "name": "databaseB"
+        },
+        {
+          "name": "databaseC"
+        }
+      ],
+      "name": "dbuser4",
+      "password": "secretsecret"
+    }
+  ]
+}
+`)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+func HandleListUsersSuccessfully(t *testing.T, instanceID string) {
+	th.Mux.HandleFunc("/instances/"+instanceID+"/users", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+	"users": [
+		{
+			"databases": [
+				{
+					"name": "databaseA"
+				}
+			],
+			"name": "dbuser3"
+		},
+		{
+			"databases": [
+				{
+					"name": "databaseB"
+				},
+				{
+					"name": "databaseC"
+				}
+			],
+			"name": "dbuser4"
+		}
+	]
+}
+`)
+	})
+}
+
+func HandleDeleteUserSuccessfully(t *testing.T, instanceID, dbName string) {
+	th.Mux.HandleFunc("/instances/"+instanceID+"/users/"+dbName, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/db/v1/users/requests.go b/openstack/db/v1/users/requests.go
new file mode 100644
index 0000000..529f43f
--- /dev/null
+++ b/openstack/db/v1/users/requests.go
@@ -0,0 +1,112 @@
+package users
+
+import (
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type CreateOptsBuilder interface {
+	ToUserCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the struct responsible for configuring a new user; often in the
+// context of an instance.
+type CreateOpts struct {
+	// Specifies a name for the user.
+	Name string
+
+	// Specifies a password for the user.
+	Password string
+
+	// An array of databases that this user will connect to. The `name` field is
+	// the only requirement for each option.
+	Databases db.BatchCreateOpts
+
+	// Specifies the host from which a user is allowed to connect to the database.
+	// Possible values are a string containing an IPv4 address or "%" to allow
+	// connecting from any host. Optional; the default is "%".
+	Host string
+}
+
+func (opts CreateOpts) ToMap() (map[string]interface{}, error) {
+	user := map[string]interface{}{}
+
+	if opts.Name != "" {
+		user["name"] = opts.Name
+	}
+	if opts.Password != "" {
+		user["password"] = opts.Password
+	}
+	if opts.Host != "" {
+		user["host"] = opts.Host
+	}
+
+	var dbs []map[string]string
+	for _, db := range opts.Databases {
+		dbs = append(dbs, map[string]string{"name": db.Name})
+	}
+	if len(dbs) > 0 {
+		user["databases"] = dbs
+	}
+
+	return user, nil
+}
+
+type BatchCreateOpts []CreateOpts
+
+func (opts BatchCreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+	var users []map[string]interface{}
+	for _, opt := range opts {
+		user, err := opt.ToMap()
+		if err != nil {
+			return nil, err
+		}
+		users = append(users, user)
+	}
+	return map[string]interface{}{"users": users}, nil
+}
+
+func Create(client *gophercloud.ServiceClient, instanceID string, opts CreateOptsBuilder) CreateResult {
+	var res CreateResult
+
+	reqBody, err := opts.ToUserCreateMap()
+	if err != nil {
+		res.Err = err
+		return res
+	}
+
+	resp, err := perigee.Request("POST", baseURL(client, instanceID), perigee.Options{
+		MoreHeaders: client.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		OkCodes:     []int{202},
+	})
+
+	res.Header = resp.HttpResponse.Header
+	res.Err = err
+
+	return res
+}
+
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+	createPageFn := func(r pagination.PageResult) pagination.Page {
+		return UserPage{pagination.LinkedPageBase{PageResult: r}}
+	}
+
+	return pagination.NewPager(client, baseURL(client, instanceID), createPageFn)
+}
+
+func Delete(client *gophercloud.ServiceClient, instanceID, userName string) DeleteResult {
+	var res DeleteResult
+
+	resp, err := perigee.Request("DELETE", userURL(client, instanceID, userName), perigee.Options{
+		MoreHeaders: client.AuthenticatedHeaders(),
+		OkCodes:     []int{202},
+	})
+
+	res.Header = resp.HttpResponse.Header
+	res.Err = err
+
+	return res
+}
diff --git a/openstack/db/v1/users/requests_test.go b/openstack/db/v1/users/requests_test.go
new file mode 100644
index 0000000..00c4e58
--- /dev/null
+++ b/openstack/db/v1/users/requests_test.go
@@ -0,0 +1,93 @@
+package users
+
+import (
+	"testing"
+
+	db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+	fake "github.com/rackspace/gophercloud/testhelper/client"
+)
+
+const instanceID = "{instanceID}"
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleCreateUserSuccessfully(t, instanceID)
+
+	opts := BatchCreateOpts{
+		CreateOpts{
+			Databases: db.BatchCreateOpts{
+				db.CreateOpts{Name: "databaseA"},
+			},
+			Name:     "dbuser3",
+			Password: "secretsecret",
+		},
+		CreateOpts{
+			Databases: db.BatchCreateOpts{
+				db.CreateOpts{Name: "databaseB"},
+				db.CreateOpts{Name: "databaseC"},
+			},
+			Name:     "dbuser4",
+			Password: "secretsecret",
+		},
+	}
+
+	res := Create(fake.ServiceClient(), instanceID, opts)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestUserList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleListUsersSuccessfully(t, instanceID)
+
+	expectedUsers := []User{
+		User{
+			Databases: []db.Database{
+				db.Database{Name: "databaseA"},
+			},
+			Name: "dbuser3",
+		},
+		User{
+			Databases: []db.Database{
+				db.Database{Name: "databaseB"},
+				db.Database{Name: "databaseC"},
+			},
+			Name: "dbuser4",
+		},
+	}
+
+	pages := 0
+	err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractUsers(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, expectedUsers, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+
+	if pages != 1 {
+		t.Errorf("Expected 1 page, saw %d", pages)
+	}
+}
+
+func TestDeleteInstance(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleDeleteUserSuccessfully(t, instanceID, "{dbName}")
+
+	res := Delete(fake.ServiceClient(), instanceID, "{dbName}")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/users/results.go b/openstack/db/v1/users/results.go
new file mode 100644
index 0000000..ce07e76
--- /dev/null
+++ b/openstack/db/v1/users/results.go
@@ -0,0 +1,71 @@
+package users
+
+import (
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+	db "github.com/rackspace/gophercloud/openstack/db/v1/databases"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+// User represents a database user
+type User struct {
+	// The user name
+	Name string
+
+	// The user password
+	Password string
+
+	// The databases associated with this user
+	Databases []db.Database
+}
+
+type CreateResult struct {
+	gophercloud.ErrResult
+}
+
+// UserPage represents a single page of a paginated user collection.
+type UserPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page UserPage) IsEmpty() (bool, error) {
+	users, err := ExtractUsers(page)
+	if err != nil {
+		return true, err
+	}
+	return len(users) == 0, nil
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page UserPage) NextPageURL() (string, error) {
+	type resp struct {
+		Links []gophercloud.Link `mapstructure:"users_links"`
+	}
+
+	var r resp
+	err := mapstructure.Decode(page.Body, &r)
+	if err != nil {
+		return "", err
+	}
+
+	return gophercloud.ExtractNextURL(r.Links)
+}
+
+// ExtractUsers will convert a generic pagination struct into a more
+// relevant slice of User structs.
+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
+}
+
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/db/v1/users/urls.go b/openstack/db/v1/users/urls.go
new file mode 100644
index 0000000..2a3cacd
--- /dev/null
+++ b/openstack/db/v1/users/urls.go
@@ -0,0 +1,11 @@
+package users
+
+import "github.com/rackspace/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient, instanceID string) string {
+	return c.ServiceURL("instances", instanceID, "users")
+}
+
+func userURL(c *gophercloud.ServiceClient, instanceID, userName string) string {
+	return c.ServiceURL("instances", instanceID, "users", userName)
+}