Skip to content

Commit

Permalink
Add HMAC key support (#8430)
Browse files Browse the repository at this point in the history
* Add 'HMACKeyMetadata' resource class.
* Add 'list_hmac_keys' / 'create_hmac_key' / `get_hmac_key_metadata' methods to client.

Closes #7851.
  • Loading branch information
tseaver authored Aug 7, 2019
1 parent 4025336 commit 2eb2969
Show file tree
Hide file tree
Showing 5 changed files with 1,008 additions and 2 deletions.
110 changes: 110 additions & 0 deletions storage/google/cloud/storage/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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
207 changes: 207 additions & 0 deletions storage/google/cloud/storage/hmac_key.py
Original file line number Diff line number Diff line change
@@ -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)
49 changes: 49 additions & 0 deletions storage/tests/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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):
Expand Down
Loading

0 comments on commit 2eb2969

Please sign in to comment.