Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Storage: add 'bucket_policy_only' IAM property #7066

Merged
merged 14 commits into from
Feb 6, 2019
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion storage/google/cloud/storage/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -1533,7 +1533,7 @@ def update_storage_class(self, new_class, client=None):
raise ValueError("Invalid storage class: %s" % (new_class,))

# Update current blob's storage class prior to rewrite
self._patch_property('storageClass', new_class)
self._patch_property("storageClass", new_class)

# Execute consecutive rewrite operations until operation is done
token, _, _ = self.rewrite(self)
Expand Down
86 changes: 86 additions & 0 deletions storage/google/cloud/storage/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,82 @@ def from_api_repr(cls, resource):
return instance


class IAMConfiguration(dict):

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""Map a bucket's IAM configuration.

:type bucket: :class:`Bucket`
:params bucket: Bucket for which this instance is the policy.

:type enabled: bool
:params enabled: (optional) whether the IAM-only policy is enabled for the bucket.

:type locked_time: :class:`datetime.datetime`
:params locked_time: (optional) When the bucket's IAM-only policy was ehabled. This value should normally only be set by the back-end API.
"""

def __init__(self, bucket, enabled=False, locked_time=None):
data = {"bucketPolicyOnly": {"enabled": enabled}}
if locked_time is not None:
data["bucketPolicyOnly"]["lockedTime"] = _datetime_to_rfc3339(locked_time)
super(IAMConfiguration, self).__init__(data)
self._bucket = bucket

@classmethod
def from_api_repr(cls, resource, bucket):
"""Factory: construct instance from resource.

:type bucket: :class:`Bucket`
:params bucket: Bucket for which this instance is the policy.

:type resource: dict
:param resource: mapping as returned from API call.

:rtype: :class:`IAMConfiguration`
:returns: Instance created from resource.
"""
instance = cls(bucket)
instance.update(resource)
return instance

@property
def bucket(self):
"""Bucket for which this instance is the policy.

:rtype: :class:`Bucket`
:returns: the instance's bucket.
"""
return self._bucket

@property
def bucket_policy_only(self):
"""Is the bucket configured to allow only IAM policy?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From discovery document. "If set, access checks only use bucket-level IAM policies or above."

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


:rtype: bool
:returns: whether the bucket is configured to allow only IAM.
"""
bpo = self.get("bucketPolicyOnly", {})
return bpo.get("enabled", False)

@bucket_policy_only.setter
def bucket_policy_only(self, value):
bpo = self.setdefault("bucketPolicyOnly", {})
bpo["enabled"] = bool(value)
self.bucket._patch_property("iamConfiguration", self)

@property
def locked_time(self):
Copy link
Member

@frankyn frankyn Jan 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @tseaver, I just realized this isn't scoped according to BucketPolicyOnly only. Could you move this to be at the same context of bucket_policy_only?

I'm writing samples and I missed this during review. I'm thinking it should have the following pattern.

iam_configuration.bucket_policy_only_enabled
iam_configuration.bucket_policy_only_locked_time

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""When was the bucket configured to allow only IAM policy?

:rtype: Union[:class:`datetime.datetime`, None]
:returns: (readonly) the time the bucket's IAM-only policy was set.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use discovery document description for lockedTime:
The deadline time for changing iamConfiguration.bucketPolicyOnly.enabled from true to false in RFC 3339 format. iamConfiguration.bucketPolicyOnly.enabled may be changed from true to false until the locked time, after which the field is immutable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""
bpo = self.get("bucketPolicyOnly", {})
stamp = bpo.get("lockedTime")
if stamp is not None:
stamp = _rfc3339_to_datetime(stamp)
return stamp


class Bucket(_PropertyMixin):
"""A class representing a Bucket on Cloud Storage.

Expand Down Expand Up @@ -1134,6 +1210,16 @@ def id(self):
"""
return self._properties.get("id")

@property
def iam_configuration(self):
"""Retrieve IAM configuration for this bucket.

:rtype: :class:`IAMConfiguration`
:returns: an instance for managing the bucket's IAM configuration.
"""
info = self._properties.get("iamConfiguration", {})
return IAMConfiguration.from_api_repr(info, self)

@property
def lifecycle_rules(self):
"""Retrieve or set lifecycle rules configured for this bucket.
Expand Down
85 changes: 85 additions & 0 deletions storage/tests/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -1470,3 +1470,88 @@ def test_bucket_lock_retention_policy(self):
bucket.retention_period = None
with self.assertRaises(exceptions.Forbidden):
bucket.patch()


class TestIAMConfiguration(unittest.TestCase):
def setUp(self):
self.case_buckets_to_delete = []

def tearDown(self):
for bucket_name in self.case_buckets_to_delete:
bucket = Config.CLIENT.bucket(bucket_name)
retry_429(bucket.delete)(force=True)

def test_new_bucket_w_bpo(self):
new_bucket_name = "new-w-bpo" + unique_resource_id("-")
self.assertRaises(
exceptions.NotFound, Config.CLIENT.get_bucket, new_bucket_name
)
bucket = Config.CLIENT.bucket(new_bucket_name)
bucket.iam_configuration.bucket_policy_only = True
retry_429(bucket.create)()
self.case_buckets_to_delete.append(new_bucket_name)

bucket_acl = bucket.acl
with self.assertRaises(exceptions.BadRequest):
bucket_acl.reload()

bucket_acl.loaded = True # Fake that we somehow loaded the ACL
bucket_acl.all().grant_read()
with self.assertRaises(exceptions.BadRequest):
bucket_acl.save()

blob_name = "my-blob.txt"
blob = bucket.blob(blob_name)
payload = b"DEADBEEF"
blob.upload_from_string(payload)

found = bucket.get_blob(blob_name)
self.assertEqual(found.download_as_string(), payload)

blob_acl = blob.acl
with self.assertRaises(exceptions.BadRequest):
blob_acl.reload()

blob_acl.loaded = True # Fake that we somehow loaded the ACL
blob_acl.all().grant_read()
with self.assertRaises(exceptions.BadRequest):
blob_acl.save()

def test_bpo_set_unset_preserves_acls(self):
new_bucket_name = "bpo-acls" + unique_resource_id("-")
self.assertRaises(
exceptions.NotFound, Config.CLIENT.get_bucket, new_bucket_name
)
bucket = retry_429(Config.CLIENT.create_bucket)(new_bucket_name)
self.case_buckets_to_delete.append(new_bucket_name)

blob_name = "my-blob.txt"
blob = bucket.blob(blob_name)
payload = b"DEADBEEF"
blob.upload_from_string(payload)
blob_policy = blob.get_iam_policy()

# Preserve ACLs before setting BPO
bucket_acl_before = list(bucket.acl)
blob_acl_before = list(bucket.acl)

# Set BPO
bucket.iam_configuration.bucket_policy_only = True
bucket.patch()

# While BPO is set, cannot get / set ACLs
with self.assertRaises(exceptions.BadRequest):
bucket.acl.reload()

# Clear BPO
bucket.iam_configuration.bucket_policy_only = False
bucket.patch()

# Query ACLs after clearing BPO
bucket.acl.reload()
bucket_acl_after = list(bucket.acl)
blob.acl.reload()
blob_acl_after = list(bucket.acl)

self.assertEqual(bucket_acl_before, bucket_acl_after)
self.assertEqual(blob_acl_before, blob_acl_after)
8 changes: 4 additions & 4 deletions storage/tests/unit/test_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -2508,13 +2508,13 @@ def test_update_storage_class_large_file(self):
"objectSize": 84,
"done": False,
"rewriteToken": TOKEN,
"resource": {"storageClass": STORAGE_CLASS}
"resource": {"storageClass": STORAGE_CLASS},
}
COMPLETE_RESPONSE = {
"totalBytesRewritten": 84,
"objectSize": 84,
"done": True,
"resource": {"storageClass": STORAGE_CLASS}
"resource": {"storageClass": STORAGE_CLASS},
}
response_1 = ({"status": http_client.OK}, INCOMPLETE_RESPONSE)
response_2 = ({"status": http_client.OK}, COMPLETE_RESPONSE)
Expand All @@ -2534,7 +2534,7 @@ def test_update_storage_class_wo_encryption_key(self):
"totalBytesRewritten": 42,
"objectSize": 42,
"done": True,
"resource": {"storageClass": STORAGE_CLASS}
"resource": {"storageClass": STORAGE_CLASS},
}
response = ({"status": http_client.OK}, RESPONSE)
connection = _Connection(response)
Expand Down Expand Up @@ -2579,7 +2579,7 @@ def test_update_storage_class_w_encryption_key_w_user_project(self):
"totalBytesRewritten": 42,
"objectSize": 42,
"done": True,
"resource": {"storageClass": STORAGE_CLASS}
"resource": {"storageClass": STORAGE_CLASS},
}
response = ({"status": http_client.OK}, RESPONSE)
connection = _Connection(response)
Expand Down
140 changes: 140 additions & 0 deletions storage/tests/unit/test_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,108 @@ def test_from_api_repr(self):
self.assertEqual(dict(rule), resource)


class Test_IAMConfiguration(unittest.TestCase):
@staticmethod
def _get_target_class():
from google.cloud.storage.bucket import IAMConfiguration

return IAMConfiguration

def _make_one(self, bucket, **kw):
return self._get_target_class()(bucket, **kw)

@staticmethod
def _make_bucket():
from google.cloud.storage.bucket import Bucket

return mock.create_autospec(Bucket, instance=True)

def test_ctor_defaults(self):
bucket = self._make_bucket()

config = self._make_one(bucket)

self.assertIs(config.bucket, bucket)
self.assertFalse(config.bucket_policy_only)
self.assertIsNone(config.locked_time)

def test_ctor_explicit(self):
import datetime
import pytz

bucket = self._make_bucket()
now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC)

config = self._make_one(bucket, enabled=True, locked_time=now)

self.assertIs(config.bucket, bucket)
self.assertTrue(config.bucket_policy_only)
self.assertEqual(config.locked_time, now)

def test_from_api_repr_w_empty_resource(self):
klass = self._get_target_class()
bucket = self._make_bucket()
resource = {}

config = klass.from_api_repr(resource, bucket)

self.assertIs(config.bucket, bucket)
self.assertFalse(config.bucket_policy_only)
self.assertIsNone(config.locked_time)

def test_from_api_repr_w_empty_bpo(self):
klass = self._get_target_class()
bucket = self._make_bucket()
resource = {"bucketPolicyOnly": {}}

config = klass.from_api_repr(resource, bucket)

self.assertIs(config.bucket, bucket)
self.assertFalse(config.bucket_policy_only)
self.assertIsNone(config.locked_time)

def test_from_api_repr_w_disabled(self):
klass = self._get_target_class()
bucket = self._make_bucket()
resource = {"bucketPolicyOnly": {"enabled": False}}

config = klass.from_api_repr(resource, bucket)

self.assertIs(config.bucket, bucket)
self.assertFalse(config.bucket_policy_only)
self.assertIsNone(config.locked_time)

def test_from_api_repr_w_enabled(self):
import datetime
import pytz
from google.cloud._helpers import _datetime_to_rfc3339

klass = self._get_target_class()
bucket = self._make_bucket()
now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC)
resource = {
"bucketPolicyOnly": {
"enabled": True,
"lockedTime": _datetime_to_rfc3339(now),
}
}

config = klass.from_api_repr(resource, bucket)

self.assertIs(config.bucket, bucket)
self.assertTrue(config.bucket_policy_only)
self.assertEqual(config.locked_time, now)

def test_bucket_policy_only_setter(self):
bucket = self._make_bucket()
config = self._make_one(bucket)

config.bucket_policy_only = True

self.assertTrue(config["bucketPolicyOnly"]["enabled"])
bucket._patch_property.assert_called_once_with("iamConfiguration", config)


class Test_Bucket(unittest.TestCase):
@staticmethod
def _get_target_class():
Expand Down Expand Up @@ -1092,6 +1194,44 @@ def test_location_setter(self, mock_warn):
bucket_module._LOCATION_SETTER_MESSAGE, DeprecationWarning, stacklevel=2
)

def test_iam_configuration_policy_missing(self):
from google.cloud.storage.bucket import IAMConfiguration

NAME = "name"
bucket = self._make_one(name=NAME)

config = bucket.iam_configuration

self.assertIsInstance(config, IAMConfiguration)
self.assertIs(config.bucket, bucket)
self.assertFalse(config.bucket_policy_only)
self.assertIsNone(config.locked_time)

def test_iam_configuration_policy_w_entry(self):
import datetime
import pytz
from google.cloud._helpers import _datetime_to_rfc3339
from google.cloud.storage.bucket import IAMConfiguration

now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC)
NAME = "name"
properties = {
"iamConfiguration": {
"bucketPolicyOnly": {
"enabled": True,
"lockedTime": _datetime_to_rfc3339(now),
}
}
}
bucket = self._make_one(name=NAME, properties=properties)

config = bucket.iam_configuration

self.assertIsInstance(config, IAMConfiguration)
self.assertIs(config.bucket, bucket)
self.assertTrue(config.bucket_policy_only)
self.assertEqual(config.locked_time, now)

def test_lifecycle_rules_getter_unknown_action_type(self):
NAME = "name"
BOGUS_RULE = {"action": {"type": "Bogus"}, "condition": {"age": 42}}
Expand Down