Skip to content

Commit 132b64d

Browse files
authored
Merge pull request boto#3799 from houglum/develop
Support fetching GCS bucket encryption metadata.
2 parents 5334015 + c4dbe6f commit 132b64d

File tree

7 files changed

+218
-7
lines changed

7 files changed

+218
-7
lines changed

boto/exception.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,9 +475,12 @@ def __init__(self, message):
475475
self.message = message
476476

477477

478-
class NoAuthHandlerFound(Exception):
479-
"""Is raised when no auth handlers were found ready to authenticate."""
480-
pass
478+
class InvalidEncryptionConfigError(Exception):
479+
"""Exception raised when GCS encryption configuration XML is invalid."""
480+
481+
def __init__(self, message):
482+
super(InvalidEncryptionConfigError, self).__init__(message)
483+
self.message = message
481484

482485

483486
class InvalidLifecycleConfigError(Exception):
@@ -488,6 +491,11 @@ def __init__(self, message):
488491
self.message = message
489492

490493

494+
class NoAuthHandlerFound(Exception):
495+
"""Is raised when no auth handlers were found ready to authenticate."""
496+
pass
497+
498+
491499
# Enum class for resumable upload failure disposition.
492500
class ResumableTransferDisposition(object):
493501
# START_OVER means an attempt to resume an existing transfer failed,

boto/gs/bucket.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from boto.gs.acl import SupportedPermissions as GSPermissions
3333
from boto.gs.bucketlistresultset import VersionedBucketListResultSet
3434
from boto.gs.cors import Cors
35+
from boto.gs.encryptionconfig import EncryptionConfig
3536
from boto.gs.lifecycle import LifecycleConfig
3637
from boto.gs.key import Key as GSKey
3738
from boto.s3.acl import Policy
@@ -43,6 +44,7 @@
4344
DEF_OBJ_ACL = 'defaultObjectAcl'
4445
STANDARD_ACL = 'acl'
4546
CORS_ARG = 'cors'
47+
ENCRYPTION_CONFIG_ARG = 'encryptionConfig'
4648
LIFECYCLE_ARG = 'lifecycle'
4749
STORAGE_CLASS_ARG='storageClass'
4850
ERROR_DETAILS_REGEX = re.compile(r'<Details>(?P<details>.*)</Details>')
@@ -51,12 +53,19 @@ class Bucket(S3Bucket):
5153
"""Represents a Google Cloud Storage bucket."""
5254

5355
BillingBody = ('<?xml version="1.0" encoding="UTF-8"?>\n'
54-
'<BillingConfiguration><RequesterPays>%s</RequesterPays>'
56+
'<BillingConfiguration>'
57+
'<RequesterPays>%s</RequesterPays>'
5558
'</BillingConfiguration>')
59+
EncryptionConfigBody = (
60+
'<?xml version="1.0" encoding="UTF-8"?>\n'
61+
'<EncryptionConfiguration>%s</EncryptionConfiguration>')
62+
EncryptionConfigDefaultKeyNameFragment = (
63+
'<DefaultKmsKeyName>%s</DefaultKmsKeyName>')
5664
StorageClassBody = ('<?xml version="1.0" encoding="UTF-8"?>\n'
5765
'<StorageClass>%s</StorageClass>')
5866
VersioningBody = ('<?xml version="1.0" encoding="UTF-8"?>\n'
59-
'<VersioningConfiguration><Status>%s</Status>'
67+
'<VersioningConfiguration>'
68+
'<Status>%s</Status>'
6069
'</VersioningConfiguration>')
6170
WebsiteBody = ('<?xml version="1.0" encoding="UTF-8"?>\n'
6271
'<WebsiteConfiguration>%s%s</WebsiteConfiguration>')
@@ -1065,3 +1074,61 @@ def configure_billing(self, requester_pays=False, headers=None):
10651074
else:
10661075
req_body = self.BillingBody % ('Disabled')
10671076
self.set_subresource('billing', req_body, headers=headers)
1077+
1078+
def get_encryption_config(self, headers=None):
1079+
"""Returns a bucket's EncryptionConfig.
1080+
1081+
:param dict headers: Additional headers to send with the request.
1082+
:rtype: :class:`~.encryption_config.EncryptionConfig`
1083+
"""
1084+
response = self.connection.make_request(
1085+
'GET', self.name, query_args=ENCRYPTION_CONFIG_ARG, headers=headers)
1086+
body = response.read()
1087+
if response.status == 200:
1088+
# Success - parse XML and return EncryptionConfig object.
1089+
encryption_config = EncryptionConfig()
1090+
h = handler.XmlHandler(encryption_config, self)
1091+
xml.sax.parseString(body, h)
1092+
return encryption_config
1093+
else:
1094+
raise self.connection.provider.storage_response_error(
1095+
response.status, response.reason, body)
1096+
1097+
def _construct_encryption_config_xml(self, default_kms_key_name=None):
1098+
"""Creates an XML document for setting a bucket's EncryptionConfig.
1099+
1100+
This method is internal as it's only here for testing purposes. As
1101+
managing Cloud KMS resources for testing is complex, we settle for
1102+
testing that we're creating correctly-formed XML for setting a bucket's
1103+
encryption configuration.
1104+
1105+
:param str default_kms_key_name: A string containing a fully-qualified
1106+
Cloud KMS key name.
1107+
:rtype: str
1108+
"""
1109+
if default_kms_key_name:
1110+
default_kms_key_name_frag = (
1111+
self.EncryptionConfigDefaultKeyNameFragment %
1112+
default_kms_key_name)
1113+
else:
1114+
default_kms_key_name_frag = ''
1115+
1116+
return self.EncryptionConfigBody % default_kms_key_name_frag
1117+
1118+
1119+
def set_encryption_config(self, default_kms_key_name=None, headers=None):
1120+
"""Sets a bucket's EncryptionConfig XML document.
1121+
1122+
:param str default_kms_key_name: A string containing a fully-qualified
1123+
Cloud KMS key name.
1124+
:param dict headers: Additional headers to send with the request.
1125+
"""
1126+
body = self._construct_encryption_config_xml(
1127+
default_kms_key_name=default_kms_key_name)
1128+
response = self.connection.make_request(
1129+
'PUT', get_utf8_value(self.name), data=get_utf8_value(body),
1130+
query_args=ENCRYPTION_CONFIG_ARG, headers=headers)
1131+
body = response.read()
1132+
if response.status != 200:
1133+
raise self.connection.provider.storage_response_error(
1134+
response.status, response.reason, body)

boto/gs/encryptionconfig.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright 2018 Google Inc.
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a
4+
# copy of this software and associated documentation files (the
5+
# "Software"), to deal in the Software without restriction, including
6+
# without limitation the rights to use, copy, modify, merge, publish, dis-
7+
# tribute, sublicense, and/or sell copies of the Software, and to permit
8+
# persons to whom the Software is furnished to do so, subject to the fol-
9+
# lowing conditions:
10+
#
11+
# The above copyright notice and this permission notice shall be included
12+
# in all copies or substantial portions of the Software.
13+
#
14+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15+
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
16+
# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
17+
# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
18+
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
20+
# IN THE SOFTWARE.
21+
22+
import types
23+
from boto.gs.user import User
24+
from boto.exception import InvalidEncryptionConfigError
25+
from xml.sax import handler
26+
27+
# Relevant tags for the EncryptionConfiguration XML document.
28+
DEFAULT_KMS_KEY_NAME = 'DefaultKmsKeyName'
29+
ENCRYPTION_CONFIG = 'EncryptionConfiguration'
30+
31+
class EncryptionConfig(handler.ContentHandler):
32+
"""Encapsulates the EncryptionConfiguration XML document"""
33+
def __init__(self):
34+
# Valid items in an EncryptionConfiguration XML node.
35+
self.default_kms_key_name = None
36+
37+
self.parse_level = 0
38+
39+
def validateParseLevel(self, tag, level):
40+
"""Verify parse level for a given tag."""
41+
if self.parse_level != level:
42+
raise InvalidEncryptionConfigError(
43+
'Invalid tag %s at parse level %d: ' % (tag, self.parse_level))
44+
45+
def startElement(self, name, attrs, connection):
46+
"""SAX XML logic for parsing new element found."""
47+
if name == ENCRYPTION_CONFIG:
48+
self.validateParseLevel(name, 0)
49+
self.parse_level += 1;
50+
elif name == DEFAULT_KMS_KEY_NAME:
51+
self.validateParseLevel(name, 1)
52+
self.parse_level += 1;
53+
else:
54+
raise InvalidEncryptionConfigError('Unsupported tag ' + name)
55+
56+
def endElement(self, name, value, connection):
57+
"""SAX XML logic for parsing new element found."""
58+
if name == ENCRYPTION_CONFIG:
59+
self.validateParseLevel(name, 1)
60+
self.parse_level -= 1;
61+
elif name == DEFAULT_KMS_KEY_NAME:
62+
self.validateParseLevel(name, 2)
63+
self.parse_level -= 1;
64+
self.default_kms_key_name = value.strip()
65+
else:
66+
raise InvalidEncryptionConfigError('Unsupported end tag ' + name)
67+
68+
def to_xml(self):
69+
"""Convert EncryptionConfig object into XML string representation."""
70+
s = ['<%s>' % ENCRYPTION_CONFIG]
71+
if self.default_kms_key_name:
72+
s.append('<%s>%s</%s>' % (DEFAULT_KMS_KEY_NAME,
73+
self.default_kms_key_name,
74+
DEFAULT_KMS_KEY_NAME))
75+
s.append('</%s>' % ENCRYPTION_CONFIG)
76+
return ''.join(s)

boto/storage_uri.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,25 @@ def configure_billing(self, requester_pays=False, validate=False,
821821
bucket = self.get_bucket(validate, headers)
822822
bucket.configure_billing(requester_pays=requester_pays, headers=headers)
823823

824+
def get_encryption_config(self, validate=False, headers=None):
825+
"""Returns a GCS bucket's encryption configuration."""
826+
self._check_bucket_uri('get_encryption_config')
827+
# EncryptionConfiguration is defined as a bucket param for GCS, but not
828+
# for S3.
829+
if self.scheme != 'gs':
830+
raise ValueError('get_encryption_config() not supported for %s '
831+
'URIs.' % self.scheme)
832+
bucket = self.get_bucket(validate, headers)
833+
return bucket.get_encryption_config(headers=headers)
834+
835+
def set_encryption_config(self, default_kms_key_name=None, validate=False,
836+
headers=None):
837+
"""Sets a GCS bucket's encryption configuration."""
838+
self._check_bucket_uri('set_encryption_config')
839+
bucket = self.get_bucket(validate, headers)
840+
bucket.set_encryption_config(default_kms_key_name=default_kms_key_name,
841+
headers=headers)
842+
824843
def exists(self, headers=None):
825844
"""Returns True if the object exists or False if it doesn't"""
826845
if not self.object_name:

boto/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@
9393
# billing is a QSA for buckets in Google Cloud Storage.
9494
'billing',
9595
# userProject is a QSA for requests in Google Cloud Storage.
96-
'userProject']
96+
'userProject',
97+
# encryptionConfig is a QSA for requests in Google Cloud
98+
# Storage.
99+
'encryptionConfig']
97100

98101

99102
_first_cap_regex = re.compile('(.)([A-Z][a-z]+)')

tests/integration/gs/test_basic.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@
5151
'<ResponseHeader>bar</ResponseHeader></ResponseHeaders>'
5252
'</Cors></CorsConfig>')
5353

54+
ENCRYPTION_CONFIG_WITH_KEY = (
55+
'<?xml version="1.0" encoding="UTF-8"?>\n'
56+
'<EncryptionConfiguration>'
57+
'<DefaultKmsKeyName>%s</DefaultKmsKeyName>'
58+
'</EncryptionConfiguration>')
59+
5460
LIFECYCLE_EMPTY = ('<?xml version="1.0" encoding="UTF-8"?>'
5561
'<LifecycleConfiguration></LifecycleConfiguration>')
5662
LIFECYCLE_DOC = ('<?xml version="1.0" encoding="UTF-8"?>'
@@ -491,3 +497,34 @@ def test_billing_config_storage_uri(self):
491497
uri.configure_billing(requester_pays=False)
492498
billing = uri.get_billing_config()
493499
self.assertEqual(billing, BILLING_DISABLED)
500+
501+
def test_encryption_config_bucket(self):
502+
"""Test setting and getting of EncryptionConfig on gs Bucket objects."""
503+
# Create a new bucket.
504+
bucket = self._MakeBucket()
505+
bucket_name = bucket.name
506+
# Get EncryptionConfig and make sure it's empty.
507+
encryption_config = bucket.get_encryption_config()
508+
self.assertIsNone(encryption_config.default_kms_key_name)
509+
# Testing set functionality would require having an existing Cloud KMS
510+
# key. Since we can't hardcode a key name or dynamically create one, we
511+
# only test here that we're creating the correct XML document to send to
512+
# GCS.
513+
xmldoc = bucket._construct_encryption_config_xml(
514+
default_kms_key_name='dummykey')
515+
self.assertEqual(xmldoc, ENCRYPTION_CONFIG_WITH_KEY % 'dummykey')
516+
# Test that setting an empty encryption config works.
517+
bucket.set_encryption_config()
518+
519+
def test_encryption_config_storage_uri(self):
520+
"""Test setting and getting of EncryptionConfig with storage_uri."""
521+
# Create a new bucket.
522+
bucket = self._MakeBucket()
523+
bucket_name = bucket.name
524+
uri = storage_uri('gs://' + bucket_name)
525+
# Get EncryptionConfig and make sure it's empty.
526+
encryption_config = uri.get_encryption_config()
527+
self.assertIsNone(encryption_config.default_kms_key_name)
528+
529+
# Test that setting an empty encryption config works.
530+
uri.set_encryption_config()

tests/integration/s3/test_key.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,8 @@ def test_header_encoding(self):
423423
check.cache_control,
424424
('public,%20max-age=500', 'public, max-age=500')
425425
)
426-
self.assertEqual(remote_metadata['cache-control'], 'public,%20max-age=500')
426+
self.assertIn(remote_metadata['cache-control'],
427+
('public,%20max-age=500', 'public, max-age=500'))
427428
self.assertEqual(check.get_metadata('test-plus'), 'A plus (+)')
428429
self.assertEqual(check.content_disposition, 'filename=Sch%C3%B6ne%20Zeit.txt')
429430
self.assertEqual(remote_metadata['content-disposition'], 'filename=Sch%C3%B6ne%20Zeit.txt')

0 commit comments

Comments
 (0)