Repository: googleapis/google-cloud-python

Issue:
Storage: HMAC Service Account key support
(@tseaver) Working from the internal support request / user guide, steps are:

- [x] Add class `google.cloud.storage.hmac.HMACKeyMetadata` (representing resource returned from create / get / list operations, and passed to update).  Holds reference to a client, and maps resource entities onto properties. (#8041)
- [x] Add `Client.create_hmac_key` method, taking `service_account_email` and returning a tuple, `(metadata, secret)`, where `metadata` is an `HMACKeyMetadata` instance and `secret` is the bytes returned by the `hmacKeys.create` API. (#8043)
- [x] Add `Client.list_hmac_keys` method, taking optional `service_account_email`, `max_results`, and `show_deleted_keys`, and  returning an iterator whose items are `HMACKeyMetadata` instances. (#8043)
- [x] Add `HMACKeyMetadata.exists` method. (#8045)
- [x] Add `HMACKeyMetadata.reload` method. (#8045)
- [x] Add `HMACKeyMetadata.update` method, pushing the current value of the `HMACKeyMetadata.state` property. (#8045)
- [x] Add `HMACKeyMetadata.delete` method. (#8045)
- [x] Add system tests exercising the CRUD cycle.


Discussion:
@jkwlui I don't have any context for this feature request:  can you please provide a pointer to a design doc / specification?

Resolving patch:
diff --git a/storage/google/cloud/storage/client.py b/storage/google/cloud/storage/client.py
--- a/storage/google/cloud/storage/client.py
+++ b/storage/google/cloud/storage/client.py
@@ -26,6 +26,7 @@
 from google.cloud.storage.batch import Batch
 from google.cloud.storage.bucket import Bucket
 from google.cloud.storage.blob import Blob
+from google.cloud.storage.hmac_key import HMACKeyMetadata
 
 
 _marker = object()
@@ -475,7 +476,7 @@ def list_blobs(
             versions=versions,
             projection=projection,
             fields=fields,
-            client=self
+            client=self,
         )
 
     def list_buckets(
@@ -561,6 +562,98 @@ def list_buckets(
             extra_params=extra_params,
         )
 
+    def create_hmac_key(self, service_account_email, project_id=None):
+        """Create an HMAC key for a service account.
+
+        :type service_account_email: str
+        :param service_account_email: e-mail address of the service account
+
+        :type project_id: str
+        :param project_id: (Optional) explicit project ID for the key.
+            Defaults to the client's project.
+
+        :rtype:
+            Tuple[:class:`~google.cloud.storage.hmac_key.HMACKeyMetadata`, str]
+        :returns: metadata for the created key, plus the bytes of the key's secret, which is an 40-character base64-encoded string.
+        """
+        if project_id is None:
+            project_id = self.project
+
+        path = "/projects/{}/hmacKeys".format(project_id)
+        qs_params = {"serviceAccountEmail": service_account_email}
+        api_response = self._connection.api_request(
+            method="POST", path=path, query_params=qs_params
+        )
+        metadata = HMACKeyMetadata(self)
+        metadata._properties = api_response["metadata"]
+        secret = api_response["secret"]
+        return metadata, secret
+
+    def list_hmac_keys(
+        self,
+        max_results=None,
+        service_account_email=None,
+        show_deleted_keys=None,
+        project_id=None,
+    ):
+        """List HMAC keys for a project.
+
+        :type max_results: int
+        :param max_results:
+            (Optional) max number of keys to return in a given page.
+
+        :type service_account_email: str
+        :param service_account_email:
+            (Optional) limit keys to those created by the given service account.
+
+        :type show_deleted_keys: bool
+        :param show_deleted_keys:
+            (Optional) included deleted keys in the list. Default is to
+            exclude them.
+
+        :type project_id: str
+        :param project_id: (Optional) explicit project ID for the key.
+            Defaults to the client's project.
+
+        :rtype:
+            Tuple[:class:`~google.cloud.storage.hmac_key.HMACKeyMetadata`, str]
+        :returns: metadata for the created key, plus the bytes of the key's secret, which is an 40-character base64-encoded string.
+        """
+        if project_id is None:
+            project_id = self.project
+
+        path = "/projects/{}/hmacKeys".format(project_id)
+        extra_params = {}
+
+        if service_account_email is not None:
+            extra_params["serviceAccountEmail"] = service_account_email
+
+        if show_deleted_keys is not None:
+            extra_params["showDeletedKeys"] = show_deleted_keys
+
+        return page_iterator.HTTPIterator(
+            client=self,
+            api_request=self._connection.api_request,
+            path=path,
+            item_to_value=_item_to_hmac_key_metadata,
+            max_results=max_results,
+            extra_params=extra_params,
+        )
+
+    def get_hmac_key_metadata(self, access_id, project_id=None):
+        """Return a metadata instance for the given HMAC key.
+
+        :type access_id: str
+        :param access_id: Unique ID of an existing key.
+
+        :type project_id: str
+        :param project_id: (Optional) project ID of an existing key.
+            Defaults to client's project.
+        """
+        metadata = HMACKeyMetadata(self, access_id, project_id)
+        metadata.reload()  # raises NotFound for missing key
+        return metadata
+
 
 def _item_to_bucket(iterator, item):
     """Convert a JSON bucket to the native object.
@@ -578,3 +671,20 @@ def _item_to_bucket(iterator, item):
     bucket = Bucket(iterator.client, name)
     bucket._set_properties(item)
     return bucket
+
+
+def _item_to_hmac_key_metadata(iterator, item):
+    """Convert a JSON key metadata resource to the native object.
+
+    :type iterator: :class:`~google.api_core.page_iterator.Iterator`
+    :param iterator: The iterator that has retrieved the item.
+
+    :type item: dict
+    :param item: An item to be converted to a key metadata instance.
+
+    :rtype: :class:`~google.cloud.storage.hmac_key.HMACKeyMetadata`
+    :returns: The next key metadata instance in the page.
+    """
+    metadata = HMACKeyMetadata(iterator.client)
+    metadata._properties = item
+    return metadata
diff --git a/storage/google/cloud/storage/hmac_key.py b/storage/google/cloud/storage/hmac_key.py
new file mode 100644
--- /dev/null
+++ b/storage/google/cloud/storage/hmac_key.py
@@ -0,0 +1,207 @@
+# Copyright 2019 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from google.cloud.exceptions import NotFound
+from google.cloud._helpers import _rfc3339_to_datetime
+
+
+class HMACKeyMetadata(object):
+    """Metadata about an HMAC service account key withn Cloud Storage.
+
+    :type client: :class:`~google.cloud.stoage.client.Client`
+    :param client: client associated with the key metadata.
+
+    :type access_id: str
+    :param access_id: (Optional) unique ID of an existing key.
+
+    :type project_id: str
+    :param project_id: (Optional) project ID of an existing key.
+        Defaults to client's project.
+    """
+
+    ACTIVE_STATE = "ACTIVE"
+    """Key is active, and may be used to sign requests."""
+    INACTIVE_STATE = "INACTIVE"
+    """Key is inactive, and may not be used to sign requests.
+
+    It can be re-activated via :meth:`update`.
+    """
+    DELETED_STATE = "DELETED"
+    """Key is deleted.  It cannot be re-activated."""
+
+    _SETTABLE_STATES = (ACTIVE_STATE, INACTIVE_STATE)
+
+    def __init__(self, client, access_id=None, project_id=None):
+        self._client = client
+        self._properties = {}
+
+        if access_id is not None:
+            self._properties["accessId"] = access_id
+
+        if project_id is not None:
+            self._properties["projectId"] = project_id
+
+    def __eq__(self, other):
+        if not isinstance(other, self.__class__):
+            return NotImplemented
+
+        return self._client == other._client and self.access_id == other.access_id
+
+    def __hash__(self):
+        return hash(self._client) + hash(self.access_id)
+
+    @property
+    def access_id(self):
+        """ID of the key.
+
+        :rtype: str or None
+        :returns: unique identifier of the key within a project.
+        """
+        return self._properties.get("accessId")
+
+    @property
+    def etag(self):
+        """ETag identifying the version of the key metadata.
+
+        :rtype: str or None
+        :returns: ETag for the version of the key's metadata.
+        """
+        return self._properties.get("etag")
+
+    @property
+    def project(self):
+        """Project ID associated with the key.
+
+        :rtype: str or None
+        :returns: project identfier for the key.
+        """
+        return self._properties.get("projectId")
+
+    @property
+    def service_account_email(self):
+        """Service account e-mail address associated with the key.
+
+        :rtype: str or None
+        :returns: e-mail address for the service account which created the key.
+        """
+        return self._properties.get("serviceAccountEmail")
+
+    @property
+    def state(self):
+        """Get / set key's state.
+
+        One of:
+            - ``ACTIVE``
+            - ``INACTIVE``
+            - ``DELETED``
+
+        :rtype: str or None
+        :returns: key's current state.
+        """
+        return self._properties.get("state")
+
+    @state.setter
+    def state(self, value):
+        if value not in self._SETTABLE_STATES:
+            raise ValueError(
+                "State may only be set to one of: {}".format(
+                    ", ".join(self._SETTABLE_STATES)
+                )
+            )
+
+        self._properties["state"] = value
+
+    @property
+    def time_created(self):
+        """Retrieve the timestamp at which the HMAC key was created.
+
+        :rtype: :class:`datetime.datetime` or ``NoneType``
+        :returns: Datetime object parsed from RFC3339 valid timestamp, or
+                  ``None`` if the bucket's resource has not been loaded
+                  from the server.
+        """
+        value = self._properties.get("timeCreated")
+        if value is not None:
+            return _rfc3339_to_datetime(value)
+
+    @property
+    def updated(self):
+        """Retrieve the timestamp at which the HMAC key was created.
+
+        :rtype: :class:`datetime.datetime` or ``NoneType``
+        :returns: Datetime object parsed from RFC3339 valid timestamp, or
+                  ``None`` if the bucket's resource has not been loaded
+                  from the server.
+        """
+        value = self._properties.get("updated")
+        if value is not None:
+            return _rfc3339_to_datetime(value)
+
+    @property
+    def path(self):
+        """Resource path for the metadata's key."""
+
+        if self.access_id is None:
+            raise ValueError("No 'access_id' set.")
+
+        project = self.project
+        if project is None:
+            project = self._client.project
+
+        return "/projects/{}/hmacKeys/{}".format(project, self.access_id)
+
+    def exists(self):
+        """Determine whether or not the key for this metadata exists.
+
+        :rtype: bool
+        :returns: True if the key exists in Cloud Storage.
+        """
+        try:
+            self._client._connection.api_request(method="GET", path=self.path)
+        except NotFound:
+            return False
+        else:
+            return True
+
+    def reload(self):
+        """Reload properties from Cloud Storage.
+
+        :raises :class:`~google.api_core.exceptions.NotFound`:
+            if the key does not exist on the back-end.
+        """
+        self._properties = self._client._connection.api_request(
+            method="GET", path=self.path
+        )
+
+    def update(self):
+        """Save writable properties to Cloud Storage.
+
+        :raises :class:`~google.api_core.exceptions.NotFound`:
+            if the key does not exist on the back-end.
+        """
+        payload = {"state": self.state}
+        self._properties = self._client._connection.api_request(
+            method="PUT", path=self.path, data=payload
+        )
+
+    def delete(self):
+        """Delete the key from Cloud Storage.
+
+        :raises :class:`~google.api_core.exceptions.NotFound`:
+            if the key does not exist on the back-end.
+        """
+        if self.state != self.INACTIVE_STATE:
+            raise ValueError("Cannot delete key if not in 'INACTIVE' state.")
+
+        self._client._connection.api_request(method="DELETE", path=self.path)


Test patch:
diff --git a/storage/tests/system.py b/storage/tests/system.py
--- a/storage/tests/system.py
+++ b/storage/tests/system.py
@@ -90,6 +90,19 @@ def tearDownModule():
 
 
 class TestClient(unittest.TestCase):
+    def setUp(self):
+        self.case_hmac_keys_to_delete = []
+
+    def tearDown(self):
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        for hmac_key in self.case_hmac_keys_to_delete:
+            if hmac_key.state == HMACKeyMetadata.ACTIVE_STATE:
+                hmac_key.state = HMACKeyMetadata.INACTIVE_STATE
+                hmac_key.update()
+            if hmac_key.state == HMACKeyMetadata.INACTIVE_STATE:
+                retry_429_harder(hmac_key.delete)()
+
     def test_get_service_account_email(self):
         domain = "gs-project-accounts.iam.gserviceaccount.com"
 
@@ -102,6 +115,42 @@ def test_get_service_account_email(self):
 
         self.assertTrue(any(match for match in matches if match is not None))
 
+    def test_hmac_key_crud(self):
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        credentials = Config.CLIENT._credentials
+        email = credentials.service_account_email
+
+        before_keys = set(Config.CLIENT.list_hmac_keys())
+
+        metadata, secret = Config.CLIENT.create_hmac_key(email)
+        self.case_hmac_keys_to_delete.append(metadata)
+
+        self.assertIsInstance(secret, six.text_type)
+        self.assertEqual(len(secret), 40)
+
+        after_keys = set(Config.CLIENT.list_hmac_keys())
+        self.assertFalse(metadata in before_keys)
+        self.assertTrue(metadata in after_keys)
+
+        another = HMACKeyMetadata(Config.CLIENT)
+
+        another._properties["accessId"] = "nonesuch"
+        self.assertFalse(another.exists())
+
+        another._properties["accessId"] = metadata.access_id
+        self.assertTrue(another.exists())
+
+        another.reload()
+
+        self.assertEqual(another._properties, metadata._properties)
+
+        metadata.state = HMACKeyMetadata.INACTIVE_STATE
+        metadata.update()
+
+        metadata.delete()
+        self.case_hmac_keys_to_delete.remove(metadata)
+
 
 class TestStorageBuckets(unittest.TestCase):
     def setUp(self):
diff --git a/storage/tests/unit/test_blob.py b/storage/tests/unit/test_blob.py
--- a/storage/tests/unit/test_blob.py
+++ b/storage/tests/unit/test_blob.py
@@ -348,9 +348,7 @@ def test_public_url_w_tilde_in_name(self):
         BLOB_NAME = "foo~bar"
         bucket = _Bucket()
         blob = self._make_one(BLOB_NAME, bucket=bucket)
-        self.assertEqual(
-            blob.public_url, "https://storage.googleapis.com/name/foo~bar"
-        )
+        self.assertEqual(blob.public_url, "https://storage.googleapis.com/name/foo~bar")
 
     def test_public_url_with_non_ascii(self):
         blob_name = u"winter \N{snowman}"
diff --git a/storage/tests/unit/test_client.py b/storage/tests/unit/test_client.py
--- a/storage/tests/unit/test_client.py
+++ b/storage/tests/unit/test_client.py
@@ -651,6 +651,7 @@ def test_download_blob_to_file_with_invalid_uri(self):
 
     def test_list_blobs(self):
         from google.cloud.storage.bucket import Bucket
+
         BUCKET_NAME = "bucket-name"
 
         credentials = _make_credentials()
@@ -658,8 +659,8 @@ def test_list_blobs(self):
         connection = _make_connection({"items": []})
 
         with mock.patch(
-            'google.cloud.storage.client.Client._connection',
-            new_callable=mock.PropertyMock
+            "google.cloud.storage.client.Client._connection",
+            new_callable=mock.PropertyMock,
         ) as client_mock:
             client_mock.return_value = connection
 
@@ -671,11 +672,12 @@ def test_list_blobs(self):
             connection.api_request.assert_called_once_with(
                 method="GET",
                 path="/b/%s/o" % BUCKET_NAME,
-                query_params={"projection": "noAcl"}
+                query_params={"projection": "noAcl"},
             )
 
     def test_list_blobs_w_all_arguments_and_user_project(self):
         from google.cloud.storage.bucket import Bucket
+
         BUCKET_NAME = "name"
         USER_PROJECT = "user-project-123"
         MAX_RESULTS = 10
@@ -701,8 +703,8 @@ def test_list_blobs_w_all_arguments_and_user_project(self):
         connection = _make_connection({"items": []})
 
         with mock.patch(
-            'google.cloud.storage.client.Client._connection',
-            new_callable=mock.PropertyMock
+            "google.cloud.storage.client.Client._connection",
+            new_callable=mock.PropertyMock,
         ) as client_mock:
             client_mock.return_value = connection
 
@@ -721,9 +723,7 @@ def test_list_blobs_w_all_arguments_and_user_project(self):
 
             self.assertEqual(blobs, [])
             connection.api_request.assert_called_once_with(
-                method="GET",
-                path="/b/%s/o" % BUCKET_NAME,
-                query_params=EXPECTED
+                method="GET", path="/b/%s/o" % BUCKET_NAME, query_params=EXPECTED
             )
 
     def test_list_buckets_wo_project(self):
@@ -874,7 +874,7 @@ def test_list_buckets_all_arguments(self):
         uri_parts = urlparse(requested_url)
         self.assertEqual(parse_qs(uri_parts.query), expected_query)
 
-    def test_page_empty_response(self):
+    def test_list_buckets_page_empty_response(self):
         from google.api_core import page_iterator
 
         project = "PROJECT"
@@ -885,7 +885,7 @@ def test_page_empty_response(self):
         iterator._page = page
         self.assertEqual(list(page), [])
 
-    def test_page_non_empty_response(self):
+    def test_list_buckets_page_non_empty_response(self):
         import six
         from google.cloud.storage.bucket import Bucket
 
@@ -908,3 +908,250 @@ def dummy_response():
         self.assertEqual(page.remaining, 0)
         self.assertIsInstance(bucket, Bucket)
         self.assertEqual(bucket.name, blob_name)
+
+    def _create_hmac_key_helper(self, explicit_project=None):
+        import datetime
+        from pytz import UTC
+        from six.moves.urllib.parse import urlencode
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        PROJECT = "PROJECT"
+        ACCESS_ID = "ACCESS-ID"
+        CREDENTIALS = _make_credentials()
+        EMAIL = "storage-user-123@example.com"
+        SECRET = "a" * 40
+        now = datetime.datetime.utcnow().replace(tzinfo=UTC)
+        now_stamp = "{}Z".format(now.isoformat())
+
+        if explicit_project is not None:
+            expected_project = explicit_project
+        else:
+            expected_project = PROJECT
+
+        RESOURCE = {
+            "kind": "storage#hmacKey",
+            "metadata": {
+                "accessId": ACCESS_ID,
+                "etag": "ETAG",
+                "id": "projects/{}/hmacKeys/{}".format(PROJECT, ACCESS_ID),
+                "project": expected_project,
+                "state": "ACTIVE",
+                "serviceAccountEmail": EMAIL,
+                "timeCreated": now_stamp,
+                "updated": now_stamp,
+            },
+            "secret": SECRET,
+        }
+
+        client = self._make_one(project=PROJECT, credentials=CREDENTIALS)
+        http = _make_requests_session([_make_json_response(RESOURCE)])
+        client._http_internal = http
+
+        kwargs = {}
+        if explicit_project is not None:
+            kwargs["project_id"] = explicit_project
+
+        metadata, secret = client.create_hmac_key(service_account_email=EMAIL, **kwargs)
+
+        self.assertIsInstance(metadata, HMACKeyMetadata)
+        self.assertIs(metadata._client, client)
+        self.assertEqual(metadata._properties, RESOURCE["metadata"])
+        self.assertEqual(secret, RESOURCE["secret"])
+
+        URI = "/".join(
+            [
+                client._connection.API_BASE_URL,
+                "storage",
+                client._connection.API_VERSION,
+                "projects",
+                expected_project,
+                "hmacKeys",
+            ]
+        )
+        QS_PARAMS = {"serviceAccountEmail": EMAIL}
+        FULL_URI = "{}?{}".format(URI, urlencode(QS_PARAMS))
+        http.request.assert_called_once_with(
+            method="POST", url=FULL_URI, data=None, headers=mock.ANY
+        )
+
+    def test_create_hmac_key_defaults(self):
+        self._create_hmac_key_helper()
+
+    def test_create_hmac_key_explicit_project(self):
+        self._create_hmac_key_helper(explicit_project="other-project-456")
+
+    def test_list_hmac_keys_defaults_empty(self):
+        PROJECT = "PROJECT"
+        CREDENTIALS = _make_credentials()
+        client = self._make_one(project=PROJECT, credentials=CREDENTIALS)
+
+        http = _make_requests_session([_make_json_response({})])
+        client._http_internal = http
+
+        metadatas = list(client.list_hmac_keys())
+
+        self.assertEqual(len(metadatas), 0)
+
+        URI = "/".join(
+            [
+                client._connection.API_BASE_URL,
+                "storage",
+                client._connection.API_VERSION,
+                "projects",
+                PROJECT,
+                "hmacKeys",
+            ]
+        )
+        http.request.assert_called_once_with(
+            method="GET", url=URI, data=None, headers=mock.ANY
+        )
+
+    def test_list_hmac_keys_explicit_non_empty(self):
+        from six.moves.urllib.parse import parse_qsl
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        PROJECT = "PROJECT"
+        OTHER_PROJECT = "other-project-456"
+        MAX_RESULTS = 3
+        EMAIL = "storage-user-123@example.com"
+        ACCESS_ID = "ACCESS-ID"
+        CREDENTIALS = _make_credentials()
+        client = self._make_one(project=PROJECT, credentials=CREDENTIALS)
+
+        response = {
+            "kind": "storage#hmacKeysMetadata",
+            "items": [
+                {
+                    "kind": "storage#hmacKeyMetadata",
+                    "accessId": ACCESS_ID,
+                    "serviceAccountEmail": EMAIL,
+                }
+            ],
+        }
+
+        http = _make_requests_session([_make_json_response(response)])
+        client._http_internal = http
+
+        metadatas = list(
+            client.list_hmac_keys(
+                max_results=MAX_RESULTS,
+                service_account_email=EMAIL,
+                show_deleted_keys=True,
+                project_id=OTHER_PROJECT,
+            )
+        )
+
+        self.assertEqual(len(metadatas), len(response["items"]))
+
+        for metadata, resource in zip(metadatas, response["items"]):
+            self.assertIsInstance(metadata, HMACKeyMetadata)
+            self.assertIs(metadata._client, client)
+            self.assertEqual(metadata._properties, resource)
+
+        URI = "/".join(
+            [
+                client._connection.API_BASE_URL,
+                "storage",
+                client._connection.API_VERSION,
+                "projects",
+                OTHER_PROJECT,
+                "hmacKeys",
+            ]
+        )
+        EXPECTED_QPARAMS = {
+            "maxResults": str(MAX_RESULTS),
+            "serviceAccountEmail": EMAIL,
+            "showDeletedKeys": "True",
+        }
+        http.request.assert_called_once_with(
+            method="GET", url=mock.ANY, data=None, headers=mock.ANY
+        )
+        kwargs = http.request.mock_calls[0].kwargs
+        uri = kwargs["url"]
+        base, qparam_str = uri.split("?")
+        qparams = dict(parse_qsl(qparam_str))
+        self.assertEqual(base, URI)
+        self.assertEqual(qparams, EXPECTED_QPARAMS)
+
+    def test_get_hmac_key_metadata_wo_project(self):
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        PROJECT = "PROJECT"
+        EMAIL = "storage-user-123@example.com"
+        ACCESS_ID = "ACCESS-ID"
+        CREDENTIALS = _make_credentials()
+        client = self._make_one(project=PROJECT, credentials=CREDENTIALS)
+
+        resource = {
+            "kind": "storage#hmacKeyMetadata",
+            "accessId": ACCESS_ID,
+            "projectId": PROJECT,
+            "serviceAccountEmail": EMAIL,
+        }
+
+        http = _make_requests_session([_make_json_response(resource)])
+        client._http_internal = http
+
+        metadata = client.get_hmac_key_metadata(ACCESS_ID)
+
+        self.assertIsInstance(metadata, HMACKeyMetadata)
+        self.assertIs(metadata._client, client)
+        self.assertEqual(metadata.access_id, ACCESS_ID)
+        self.assertEqual(metadata.project, PROJECT)
+
+        URI = "/".join(
+            [
+                client._connection.API_BASE_URL,
+                "storage",
+                client._connection.API_VERSION,
+                "projects",
+                PROJECT,
+                "hmacKeys",
+                ACCESS_ID,
+            ]
+        )
+        http.request.assert_called_once_with(
+            method="GET", url=URI, data=None, headers=mock.ANY
+        )
+
+    def test_get_hmac_key_metadata_w_project(self):
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        PROJECT = "PROJECT"
+        OTHER_PROJECT = "other-project-456"
+        EMAIL = "storage-user-123@example.com"
+        ACCESS_ID = "ACCESS-ID"
+        CREDENTIALS = _make_credentials()
+        client = self._make_one(project=PROJECT, credentials=CREDENTIALS)
+
+        resource = {
+            "kind": "storage#hmacKeyMetadata",
+            "accessId": ACCESS_ID,
+            "projectId": OTHER_PROJECT,
+            "serviceAccountEmail": EMAIL,
+        }
+
+        http = _make_requests_session([_make_json_response(resource)])
+        client._http_internal = http
+
+        metadata = client.get_hmac_key_metadata(ACCESS_ID, project_id=OTHER_PROJECT)
+
+        self.assertIsInstance(metadata, HMACKeyMetadata)
+        self.assertIs(metadata._client, client)
+        self.assertEqual(metadata.access_id, ACCESS_ID)
+        self.assertEqual(metadata.project, OTHER_PROJECT)
+
+        URI = "/".join(
+            [
+                client._connection.API_BASE_URL,
+                "storage",
+                client._connection.API_VERSION,
+                "projects",
+                OTHER_PROJECT,
+                "hmacKeys",
+                ACCESS_ID,
+            ]
+        )
+        http.request.assert_called_once_with(
+            method="GET", url=URI, data=None, headers=mock.ANY
+        )
diff --git a/storage/tests/unit/test_hmac_key.py b/storage/tests/unit/test_hmac_key.py
new file mode 100644
--- /dev/null
+++ b/storage/tests/unit/test_hmac_key.py
@@ -0,0 +1,393 @@
+# Copyright 2019 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import mock
+
+
+class TestHMACKeyMetadata(unittest.TestCase):
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.storage.hmac_key import HMACKeyMetadata
+
+        return HMACKeyMetadata
+
+    def _make_one(self, client=None, *args, **kw):
+        if client is None:
+            client = _Client()
+        return self._get_target_class()(client, *args, **kw)
+
+    def test_ctor_defaults(self):
+        client = object()
+        metadata = self._make_one(client)
+        self.assertIs(metadata._client, client)
+        self.assertEqual(metadata._properties, {})
+        self.assertIsNone(metadata.access_id)
+        self.assertIsNone(metadata.etag)
+        self.assertIsNone(metadata.project)
+        self.assertIsNone(metadata.service_account_email)
+        self.assertIsNone(metadata.state)
+        self.assertIsNone(metadata.time_created)
+        self.assertIsNone(metadata.updated)
+
+    def test_ctor_explicit(self):
+        OTHER_PROJECT = "other-project-456"
+        ACCESS_ID = "access-id-123456789"
+        client = _Client()
+        metadata = self._make_one(client, access_id=ACCESS_ID, project_id=OTHER_PROJECT)
+        self.assertIs(metadata._client, client)
+        expected = {"accessId": ACCESS_ID, "projectId": OTHER_PROJECT}
+        self.assertEqual(metadata._properties, expected)
+        self.assertEqual(metadata.access_id, ACCESS_ID)
+        self.assertIsNone(metadata.etag)
+        self.assertEqual(metadata.project, OTHER_PROJECT)
+        self.assertIsNone(metadata.service_account_email)
+        self.assertIsNone(metadata.state)
+        self.assertIsNone(metadata.time_created)
+        self.assertIsNone(metadata.updated)
+
+    def test___eq___other_type(self):
+        metadata = self._make_one()
+        for bogus in (None, "bogus", 123, 456.78, [], (), {}, set()):
+            self.assertNotEqual(metadata, bogus)
+
+    def test___eq___mismatched_client(self):
+        metadata = self._make_one()
+        other_client = _Client(project="other-project-456")
+        other = self._make_one(other_client)
+        self.assertNotEqual(metadata, other)
+
+    def test___eq___mismatched_access_id(self):
+        metadata = self._make_one()
+        metadata._properties["accessId"] = "ABC123"
+        other = self._make_one(metadata._client)
+        metadata._properties["accessId"] = "DEF456"
+        self.assertNotEqual(metadata, other)
+
+    def test___eq___hit(self):
+        metadata = self._make_one()
+        metadata._properties["accessId"] = "ABC123"
+        other = self._make_one(metadata._client)
+        other._properties["accessId"] = metadata.access_id
+        self.assertEqual(metadata, other)
+
+    def test___hash__(self):
+        client = _Client()
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = "ABC123"
+        self.assertIsInstance(hash(metadata), int)
+        other = self._make_one(client)
+        metadata._properties["accessId"] = "DEF456"
+        self.assertNotEqual(hash(metadata), hash(other))
+
+    def test_access_id_getter(self):
+        metadata = self._make_one()
+        expected = "ACCESS-ID"
+        metadata._properties["accessId"] = expected
+        self.assertEqual(metadata.access_id, expected)
+
+    def test_etag_getter(self):
+        metadata = self._make_one()
+        expected = "ETAG"
+        metadata._properties["etag"] = expected
+        self.assertEqual(metadata.etag, expected)
+
+    def test_project_getter(self):
+        metadata = self._make_one()
+        expected = "PROJECT-ID"
+        metadata._properties["projectId"] = expected
+        self.assertEqual(metadata.project, expected)
+
+    def test_service_account_email_getter(self):
+        metadata = self._make_one()
+        expected = "service_account@example.com"
+        metadata._properties["serviceAccountEmail"] = expected
+        self.assertEqual(metadata.service_account_email, expected)
+
+    def test_state_getter(self):
+        metadata = self._make_one()
+        expected = "STATE"
+        metadata._properties["state"] = expected
+        self.assertEqual(metadata.state, expected)
+
+    def test_state_setter_invalid_state(self):
+        metadata = self._make_one()
+        expected = "INVALID"
+
+        with self.assertRaises(ValueError):
+            metadata.state = expected
+
+        self.assertIsNone(metadata.state)
+
+    def test_state_setter_inactive(self):
+        metadata = self._make_one()
+        metadata._properties["state"] = "ACTIVE"
+        expected = "INACTIVE"
+        metadata.state = expected
+        self.assertEqual(metadata.state, expected)
+        self.assertEqual(metadata._properties["state"], expected)
+
+    def test_state_setter_active(self):
+        metadata = self._make_one()
+        metadata._properties["state"] = "INACTIVE"
+        expected = "ACTIVE"
+        metadata.state = expected
+        self.assertEqual(metadata.state, expected)
+        self.assertEqual(metadata._properties["state"], expected)
+
+    def test_time_created_getter(self):
+        import datetime
+        from pytz import UTC
+
+        metadata = self._make_one()
+        now = datetime.datetime.utcnow()
+        now_stamp = "{}Z".format(now.isoformat())
+        metadata._properties["timeCreated"] = now_stamp
+        self.assertEqual(metadata.time_created, now.replace(tzinfo=UTC))
+
+    def test_updated_getter(self):
+        import datetime
+        from pytz import UTC
+
+        metadata = self._make_one()
+        now = datetime.datetime.utcnow()
+        now_stamp = "{}Z".format(now.isoformat())
+        metadata._properties["updated"] = now_stamp
+        self.assertEqual(metadata.updated, now.replace(tzinfo=UTC))
+
+    def test_path_wo_access_id(self):
+        metadata = self._make_one()
+
+        with self.assertRaises(ValueError):
+            metadata.path
+
+    def test_path_w_access_id_wo_project(self):
+        access_id = "ACCESS-ID"
+        client = _Client()
+        metadata = self._make_one()
+        metadata._properties["accessId"] = access_id
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(
+            client.DEFAULT_PROJECT, access_id
+        )
+        self.assertEqual(metadata.path, expected_path)
+
+    def test_path_w_access_id_w_explicit_project(self):
+        access_id = "ACCESS-ID"
+        project = "OTHER-PROJECT"
+        metadata = self._make_one()
+        metadata._properties["accessId"] = access_id
+        metadata._properties["projectId"] = project
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(project, access_id)
+        self.assertEqual(metadata.path, expected_path)
+
+    def test_exists_miss_no_project_set(self):
+        from google.cloud.exceptions import NotFound
+
+        access_id = "ACCESS-ID"
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.side_effect = NotFound("testing")
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+
+        self.assertFalse(metadata.exists())
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(
+            client.DEFAULT_PROJECT, access_id
+        )
+        expected_kwargs = {"method": "GET", "path": expected_path}
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_exists_hit_w_project_set(self):
+        project = "PROJECT-ID"
+        access_id = "ACCESS-ID"
+        email = "service-account@example.com"
+        resource = {
+            "kind": "storage#hmacKeyMetadata",
+            "accessId": access_id,
+            "serviceAccountEmail": email,
+        }
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.return_value = resource
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+        metadata._properties["projectId"] = project
+
+        self.assertTrue(metadata.exists())
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(project, access_id)
+        expected_kwargs = {"method": "GET", "path": expected_path}
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_reload_miss_no_project_set(self):
+        from google.cloud.exceptions import NotFound
+
+        access_id = "ACCESS-ID"
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.side_effect = NotFound("testing")
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+
+        with self.assertRaises(NotFound):
+            metadata.reload()
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(
+            client.DEFAULT_PROJECT, access_id
+        )
+        expected_kwargs = {"method": "GET", "path": expected_path}
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_reload_hit_w_project_set(self):
+        project = "PROJECT-ID"
+        access_id = "ACCESS-ID"
+        email = "service-account@example.com"
+        resource = {
+            "kind": "storage#hmacKeyMetadata",
+            "accessId": access_id,
+            "serviceAccountEmail": email,
+        }
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.return_value = resource
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+        metadata._properties["projectId"] = project
+
+        metadata.reload()
+
+        self.assertEqual(metadata._properties, resource)
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(project, access_id)
+        expected_kwargs = {"method": "GET", "path": expected_path}
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_update_miss_no_project_set(self):
+        from google.cloud.exceptions import NotFound
+
+        access_id = "ACCESS-ID"
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.side_effect = NotFound("testing")
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+        metadata.state = "INACTIVE"
+
+        with self.assertRaises(NotFound):
+            metadata.update()
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(
+            client.DEFAULT_PROJECT, access_id
+        )
+        expected_kwargs = {
+            "method": "PUT",
+            "path": expected_path,
+            "data": {"state": "INACTIVE"},
+        }
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_update_hit_w_project_set(self):
+        project = "PROJECT-ID"
+        access_id = "ACCESS-ID"
+        email = "service-account@example.com"
+        resource = {
+            "kind": "storage#hmacKeyMetadata",
+            "accessId": access_id,
+            "serviceAccountEmail": email,
+            "state": "ACTIVE",
+        }
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.return_value = resource
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+        metadata._properties["projectId"] = project
+        metadata.state = "ACTIVE"
+
+        metadata.update()
+
+        self.assertEqual(metadata._properties, resource)
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(project, access_id)
+        expected_kwargs = {
+            "method": "PUT",
+            "path": expected_path,
+            "data": {"state": "ACTIVE"},
+        }
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_delete_not_inactive(self):
+        metadata = self._make_one()
+        for state in ("ACTIVE", "DELETED"):
+            metadata._properties["state"] = state
+
+            with self.assertRaises(ValueError):
+                metadata.delete()
+
+    def test_delete_miss_no_project_set(self):
+        from google.cloud.exceptions import NotFound
+
+        access_id = "ACCESS-ID"
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.side_effect = NotFound("testing")
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+        metadata.state = "INACTIVE"
+
+        with self.assertRaises(NotFound):
+            metadata.delete()
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(
+            client.DEFAULT_PROJECT, access_id
+        )
+        expected_kwargs = {"method": "DELETE", "path": expected_path}
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+    def test_delete_hit_w_project_set(self):
+        project = "PROJECT-ID"
+        access_id = "ACCESS-ID"
+        connection = mock.Mock(spec=["api_request"])
+        connection.api_request.return_value = {}
+        client = _Client(connection)
+        metadata = self._make_one(client)
+        metadata._properties["accessId"] = access_id
+        metadata._properties["projectId"] = project
+        metadata.state = "INACTIVE"
+
+        metadata.delete()
+
+        expected_path = "/projects/{}/hmacKeys/{}".format(project, access_id)
+        expected_kwargs = {"method": "DELETE", "path": expected_path}
+        connection.api_request.assert_called_once_with(**expected_kwargs)
+
+
+class _Client(object):
+    DEFAULT_PROJECT = "project-123"
+
+    def __init__(self, connection=None, project=DEFAULT_PROJECT):
+        self._connection = connection
+        self.project = project
+
+    def __eq__(self, other):
+        if not isinstance(other, self.__class__):  # pragma: NO COVER
+            return NotImplemented
+        return self._connection == other._connection and self.project == other.project
+
+    def __hash__(self):
+        return hash(self._connection) + hash(self.project)