Skip to content

Commit 2eb2969

Browse files
authored
Add HMAC key support (#8430)
* Add 'HMACKeyMetadata' resource class. * Add 'list_hmac_keys' / 'create_hmac_key' / `get_hmac_key_metadata' methods to client. Closes #7851.
1 parent 4025336 commit 2eb2969

File tree

5 files changed

+1008
-2
lines changed

5 files changed

+1008
-2
lines changed

storage/google/cloud/storage/client.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from google.cloud.storage.batch import Batch
2727
from google.cloud.storage.bucket import Bucket
2828
from google.cloud.storage.blob import Blob
29+
from google.cloud.storage.hmac_key import HMACKeyMetadata
2930

3031

3132
_marker = object()
@@ -561,6 +562,98 @@ def list_buckets(
561562
extra_params=extra_params,
562563
)
563564

565+
def create_hmac_key(self, service_account_email, project_id=None):
566+
"""Create an HMAC key for a service account.
567+
568+
:type service_account_email: str
569+
:param service_account_email: e-mail address of the service account
570+
571+
:type project_id: str
572+
:param project_id: (Optional) explicit project ID for the key.
573+
Defaults to the client's project.
574+
575+
:rtype:
576+
Tuple[:class:`~google.cloud.storage.hmac_key.HMACKeyMetadata`, str]
577+
:returns: metadata for the created key, plus the bytes of the key's secret, which is an 40-character base64-encoded string.
578+
"""
579+
if project_id is None:
580+
project_id = self.project
581+
582+
path = "/projects/{}/hmacKeys".format(project_id)
583+
qs_params = {"serviceAccountEmail": service_account_email}
584+
api_response = self._connection.api_request(
585+
method="POST", path=path, query_params=qs_params
586+
)
587+
metadata = HMACKeyMetadata(self)
588+
metadata._properties = api_response["metadata"]
589+
secret = api_response["secret"]
590+
return metadata, secret
591+
592+
def list_hmac_keys(
593+
self,
594+
max_results=None,
595+
service_account_email=None,
596+
show_deleted_keys=None,
597+
project_id=None,
598+
):
599+
"""List HMAC keys for a project.
600+
601+
:type max_results: int
602+
:param max_results:
603+
(Optional) max number of keys to return in a given page.
604+
605+
:type service_account_email: str
606+
:param service_account_email:
607+
(Optional) limit keys to those created by the given service account.
608+
609+
:type show_deleted_keys: bool
610+
:param show_deleted_keys:
611+
(Optional) included deleted keys in the list. Default is to
612+
exclude them.
613+
614+
:type project_id: str
615+
:param project_id: (Optional) explicit project ID for the key.
616+
Defaults to the client's project.
617+
618+
:rtype:
619+
Tuple[:class:`~google.cloud.storage.hmac_key.HMACKeyMetadata`, str]
620+
:returns: metadata for the created key, plus the bytes of the key's secret, which is an 40-character base64-encoded string.
621+
"""
622+
if project_id is None:
623+
project_id = self.project
624+
625+
path = "/projects/{}/hmacKeys".format(project_id)
626+
extra_params = {}
627+
628+
if service_account_email is not None:
629+
extra_params["serviceAccountEmail"] = service_account_email
630+
631+
if show_deleted_keys is not None:
632+
extra_params["showDeletedKeys"] = show_deleted_keys
633+
634+
return page_iterator.HTTPIterator(
635+
client=self,
636+
api_request=self._connection.api_request,
637+
path=path,
638+
item_to_value=_item_to_hmac_key_metadata,
639+
max_results=max_results,
640+
extra_params=extra_params,
641+
)
642+
643+
def get_hmac_key_metadata(self, access_id, project_id=None):
644+
"""Return a metadata instance for the given HMAC key.
645+
646+
:type access_id: str
647+
:param access_id: Unique ID of an existing key.
648+
649+
:type project_id: str
650+
:param project_id: (Optional) project ID of an existing key.
651+
Defaults to client's project.
652+
"""
653+
metadata = HMACKeyMetadata(self, access_id, project_id)
654+
metadata.reload() # raises NotFound for missing key
655+
return metadata
656+
564657

565658
def _item_to_bucket(iterator, item):
566659
"""Convert a JSON bucket to the native object.
@@ -578,3 +671,20 @@ def _item_to_bucket(iterator, item):
578671
bucket = Bucket(iterator.client, name)
579672
bucket._set_properties(item)
580673
return bucket
674+
675+
676+
def _item_to_hmac_key_metadata(iterator, item):
677+
"""Convert a JSON key metadata resource to the native object.
678+
679+
:type iterator: :class:`~google.api_core.page_iterator.Iterator`
680+
:param iterator: The iterator that has retrieved the item.
681+
682+
:type item: dict
683+
:param item: An item to be converted to a key metadata instance.
684+
685+
:rtype: :class:`~google.cloud.storage.hmac_key.HMACKeyMetadata`
686+
:returns: The next key metadata instance in the page.
687+
"""
688+
metadata = HMACKeyMetadata(iterator.client)
689+
metadata._properties = item
690+
return metadata
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Copyright 2019 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from google.cloud.exceptions import NotFound
16+
from google.cloud._helpers import _rfc3339_to_datetime
17+
18+
19+
class HMACKeyMetadata(object):
20+
"""Metadata about an HMAC service account key withn Cloud Storage.
21+
22+
:type client: :class:`~google.cloud.stoage.client.Client`
23+
:param client: client associated with the key metadata.
24+
25+
:type access_id: str
26+
:param access_id: (Optional) unique ID of an existing key.
27+
28+
:type project_id: str
29+
:param project_id: (Optional) project ID of an existing key.
30+
Defaults to client's project.
31+
"""
32+
33+
ACTIVE_STATE = "ACTIVE"
34+
"""Key is active, and may be used to sign requests."""
35+
INACTIVE_STATE = "INACTIVE"
36+
"""Key is inactive, and may not be used to sign requests.
37+
38+
It can be re-activated via :meth:`update`.
39+
"""
40+
DELETED_STATE = "DELETED"
41+
"""Key is deleted. It cannot be re-activated."""
42+
43+
_SETTABLE_STATES = (ACTIVE_STATE, INACTIVE_STATE)
44+
45+
def __init__(self, client, access_id=None, project_id=None):
46+
self._client = client
47+
self._properties = {}
48+
49+
if access_id is not None:
50+
self._properties["accessId"] = access_id
51+
52+
if project_id is not None:
53+
self._properties["projectId"] = project_id
54+
55+
def __eq__(self, other):
56+
if not isinstance(other, self.__class__):
57+
return NotImplemented
58+
59+
return self._client == other._client and self.access_id == other.access_id
60+
61+
def __hash__(self):
62+
return hash(self._client) + hash(self.access_id)
63+
64+
@property
65+
def access_id(self):
66+
"""ID of the key.
67+
68+
:rtype: str or None
69+
:returns: unique identifier of the key within a project.
70+
"""
71+
return self._properties.get("accessId")
72+
73+
@property
74+
def etag(self):
75+
"""ETag identifying the version of the key metadata.
76+
77+
:rtype: str or None
78+
:returns: ETag for the version of the key's metadata.
79+
"""
80+
return self._properties.get("etag")
81+
82+
@property
83+
def project(self):
84+
"""Project ID associated with the key.
85+
86+
:rtype: str or None
87+
:returns: project identfier for the key.
88+
"""
89+
return self._properties.get("projectId")
90+
91+
@property
92+
def service_account_email(self):
93+
"""Service account e-mail address associated with the key.
94+
95+
:rtype: str or None
96+
:returns: e-mail address for the service account which created the key.
97+
"""
98+
return self._properties.get("serviceAccountEmail")
99+
100+
@property
101+
def state(self):
102+
"""Get / set key's state.
103+
104+
One of:
105+
- ``ACTIVE``
106+
- ``INACTIVE``
107+
- ``DELETED``
108+
109+
:rtype: str or None
110+
:returns: key's current state.
111+
"""
112+
return self._properties.get("state")
113+
114+
@state.setter
115+
def state(self, value):
116+
if value not in self._SETTABLE_STATES:
117+
raise ValueError(
118+
"State may only be set to one of: {}".format(
119+
", ".join(self._SETTABLE_STATES)
120+
)
121+
)
122+
123+
self._properties["state"] = value
124+
125+
@property
126+
def time_created(self):
127+
"""Retrieve the timestamp at which the HMAC key was created.
128+
129+
:rtype: :class:`datetime.datetime` or ``NoneType``
130+
:returns: Datetime object parsed from RFC3339 valid timestamp, or
131+
``None`` if the bucket's resource has not been loaded
132+
from the server.
133+
"""
134+
value = self._properties.get("timeCreated")
135+
if value is not None:
136+
return _rfc3339_to_datetime(value)
137+
138+
@property
139+
def updated(self):
140+
"""Retrieve the timestamp at which the HMAC key was created.
141+
142+
:rtype: :class:`datetime.datetime` or ``NoneType``
143+
:returns: Datetime object parsed from RFC3339 valid timestamp, or
144+
``None`` if the bucket's resource has not been loaded
145+
from the server.
146+
"""
147+
value = self._properties.get("updated")
148+
if value is not None:
149+
return _rfc3339_to_datetime(value)
150+
151+
@property
152+
def path(self):
153+
"""Resource path for the metadata's key."""
154+
155+
if self.access_id is None:
156+
raise ValueError("No 'access_id' set.")
157+
158+
project = self.project
159+
if project is None:
160+
project = self._client.project
161+
162+
return "/projects/{}/hmacKeys/{}".format(project, self.access_id)
163+
164+
def exists(self):
165+
"""Determine whether or not the key for this metadata exists.
166+
167+
:rtype: bool
168+
:returns: True if the key exists in Cloud Storage.
169+
"""
170+
try:
171+
self._client._connection.api_request(method="GET", path=self.path)
172+
except NotFound:
173+
return False
174+
else:
175+
return True
176+
177+
def reload(self):
178+
"""Reload properties from Cloud Storage.
179+
180+
:raises :class:`~google.api_core.exceptions.NotFound`:
181+
if the key does not exist on the back-end.
182+
"""
183+
self._properties = self._client._connection.api_request(
184+
method="GET", path=self.path
185+
)
186+
187+
def update(self):
188+
"""Save writable properties to Cloud Storage.
189+
190+
:raises :class:`~google.api_core.exceptions.NotFound`:
191+
if the key does not exist on the back-end.
192+
"""
193+
payload = {"state": self.state}
194+
self._properties = self._client._connection.api_request(
195+
method="PUT", path=self.path, data=payload
196+
)
197+
198+
def delete(self):
199+
"""Delete the key from Cloud Storage.
200+
201+
:raises :class:`~google.api_core.exceptions.NotFound`:
202+
if the key does not exist on the back-end.
203+
"""
204+
if self.state != self.INACTIVE_STATE:
205+
raise ValueError("Cannot delete key if not in 'INACTIVE' state.")
206+
207+
self._client._connection.api_request(method="DELETE", path=self.path)

storage/tests/system.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ def tearDownModule():
9090

9191

9292
class TestClient(unittest.TestCase):
93+
def setUp(self):
94+
self.case_hmac_keys_to_delete = []
95+
96+
def tearDown(self):
97+
from google.cloud.storage.hmac_key import HMACKeyMetadata
98+
99+
for hmac_key in self.case_hmac_keys_to_delete:
100+
if hmac_key.state == HMACKeyMetadata.ACTIVE_STATE:
101+
hmac_key.state = HMACKeyMetadata.INACTIVE_STATE
102+
hmac_key.update()
103+
if hmac_key.state == HMACKeyMetadata.INACTIVE_STATE:
104+
retry_429_harder(hmac_key.delete)()
105+
93106
def test_get_service_account_email(self):
94107
domain = "gs-project-accounts.iam.gserviceaccount.com"
95108

@@ -102,6 +115,42 @@ def test_get_service_account_email(self):
102115

103116
self.assertTrue(any(match for match in matches if match is not None))
104117

118+
def test_hmac_key_crud(self):
119+
from google.cloud.storage.hmac_key import HMACKeyMetadata
120+
121+
credentials = Config.CLIENT._credentials
122+
email = credentials.service_account_email
123+
124+
before_keys = set(Config.CLIENT.list_hmac_keys())
125+
126+
metadata, secret = Config.CLIENT.create_hmac_key(email)
127+
self.case_hmac_keys_to_delete.append(metadata)
128+
129+
self.assertIsInstance(secret, six.text_type)
130+
self.assertEqual(len(secret), 40)
131+
132+
after_keys = set(Config.CLIENT.list_hmac_keys())
133+
self.assertFalse(metadata in before_keys)
134+
self.assertTrue(metadata in after_keys)
135+
136+
another = HMACKeyMetadata(Config.CLIENT)
137+
138+
another._properties["accessId"] = "nonesuch"
139+
self.assertFalse(another.exists())
140+
141+
another._properties["accessId"] = metadata.access_id
142+
self.assertTrue(another.exists())
143+
144+
another.reload()
145+
146+
self.assertEqual(another._properties, metadata._properties)
147+
148+
metadata.state = HMACKeyMetadata.INACTIVE_STATE
149+
metadata.update()
150+
151+
metadata.delete()
152+
self.case_hmac_keys_to_delete.remove(metadata)
153+
105154

106155
class TestStorageBuckets(unittest.TestCase):
107156
def setUp(self):

0 commit comments

Comments
 (0)