diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 6fabaae1bbaa..4542e251bea2 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4215,6 +4215,69 @@ - [ ] write_get_object_response +## s3control +
+5% implemented + +- [ ] create_access_point +- [ ] create_access_point_for_object_lambda +- [ ] create_bucket +- [ ] create_job +- [ ] create_multi_region_access_point +- [ ] delete_access_point +- [ ] delete_access_point_for_object_lambda +- [ ] delete_access_point_policy +- [ ] delete_access_point_policy_for_object_lambda +- [ ] delete_bucket +- [ ] delete_bucket_lifecycle_configuration +- [ ] delete_bucket_policy +- [ ] delete_bucket_tagging +- [ ] delete_job_tagging +- [ ] delete_multi_region_access_point +- [X] delete_public_access_block +- [ ] delete_storage_lens_configuration +- [ ] delete_storage_lens_configuration_tagging +- [ ] describe_job +- [ ] describe_multi_region_access_point_operation +- [ ] get_access_point +- [ ] get_access_point_configuration_for_object_lambda +- [ ] get_access_point_for_object_lambda +- [ ] get_access_point_policy +- [ ] get_access_point_policy_for_object_lambda +- [ ] get_access_point_policy_status +- [ ] get_access_point_policy_status_for_object_lambda +- [ ] get_bucket +- [ ] get_bucket_lifecycle_configuration +- [ ] get_bucket_policy +- [ ] get_bucket_tagging +- [ ] get_job_tagging +- [ ] get_multi_region_access_point +- [ ] get_multi_region_access_point_policy +- [ ] get_multi_region_access_point_policy_status +- [X] get_public_access_block +- [ ] get_storage_lens_configuration +- [ ] get_storage_lens_configuration_tagging +- [ ] list_access_points +- [ ] list_access_points_for_object_lambda +- [ ] list_jobs +- [ ] list_multi_region_access_points +- [ ] list_regional_buckets +- [ ] list_storage_lens_configurations +- [ ] put_access_point_configuration_for_object_lambda +- [ ] put_access_point_policy +- [ ] put_access_point_policy_for_object_lambda +- [ ] put_bucket_lifecycle_configuration +- [ ] put_bucket_policy +- [ ] put_bucket_tagging +- [ ] put_job_tagging +- [ ] put_multi_region_access_point_policy +- [X] put_public_access_block +- [ ] put_storage_lens_configuration +- [ ] put_storage_lens_configuration_tagging +- [ ] update_job_priority +- [ ] update_job_status +
+ ## sagemaker
15% implemented @@ -5205,7 +5268,6 @@ - route53-recovery-readiness - route53domains - rum -- s3control - s3outposts - sagemaker-a2i-runtime - sagemaker-edge diff --git a/docs/docs/services/s3control.rst b/docs/docs/services/s3control.rst new file mode 100644 index 000000000000..be2b0066d471 --- /dev/null +++ b/docs/docs/services/s3control.rst @@ -0,0 +1,87 @@ +.. _implementedservice_s3control: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +========= +s3control +========= + +.. autoclass:: moto.s3control.models.S3ControlBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_s3control + def test_s3control_behaviour: + boto3.client("s3control") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [ ] create_access_point +- [ ] create_access_point_for_object_lambda +- [ ] create_bucket +- [ ] create_job +- [ ] create_multi_region_access_point +- [ ] delete_access_point +- [ ] delete_access_point_for_object_lambda +- [ ] delete_access_point_policy +- [ ] delete_access_point_policy_for_object_lambda +- [ ] delete_bucket +- [ ] delete_bucket_lifecycle_configuration +- [ ] delete_bucket_policy +- [ ] delete_bucket_tagging +- [ ] delete_job_tagging +- [ ] delete_multi_region_access_point +- [X] delete_public_access_block +- [ ] delete_storage_lens_configuration +- [ ] delete_storage_lens_configuration_tagging +- [ ] describe_job +- [ ] describe_multi_region_access_point_operation +- [ ] get_access_point +- [ ] get_access_point_configuration_for_object_lambda +- [ ] get_access_point_for_object_lambda +- [ ] get_access_point_policy +- [ ] get_access_point_policy_for_object_lambda +- [ ] get_access_point_policy_status +- [ ] get_access_point_policy_status_for_object_lambda +- [ ] get_bucket +- [ ] get_bucket_lifecycle_configuration +- [ ] get_bucket_policy +- [ ] get_bucket_tagging +- [ ] get_job_tagging +- [ ] get_multi_region_access_point +- [ ] get_multi_region_access_point_policy +- [ ] get_multi_region_access_point_policy_status +- [X] get_public_access_block +- [ ] get_storage_lens_configuration +- [ ] get_storage_lens_configuration_tagging +- [ ] list_access_points +- [ ] list_access_points_for_object_lambda +- [ ] list_jobs +- [ ] list_multi_region_access_points +- [ ] list_regional_buckets +- [ ] list_storage_lens_configurations +- [ ] put_access_point_configuration_for_object_lambda +- [ ] put_access_point_policy +- [ ] put_access_point_policy_for_object_lambda +- [ ] put_bucket_lifecycle_configuration +- [ ] put_bucket_policy +- [ ] put_bucket_tagging +- [ ] put_job_tagging +- [ ] put_multi_region_access_point_policy +- [X] put_public_access_block +- [ ] put_storage_lens_configuration +- [ ] put_storage_lens_configuration_tagging +- [ ] update_job_priority +- [ ] update_job_status + diff --git a/moto/__init__.py b/moto/__init__.py index d3020477ba4d..40e4d0059b19 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -176,6 +176,7 @@ def f(*args, **kwargs): mock_elasticache = lazy_load( ".elasticache", "mock_elasticache", boto3_name="elasticache" ) +mock_s3control = lazy_load(".s3control", "mock_s3control", boto3_name="s3control") class MockAll(ContextDecorator): @@ -206,7 +207,6 @@ def __exit__(self, *exc): __title__ = "moto" __version__ = "2.2.20.dev" - try: # Need to monkey-patch botocore requests back to underlying urllib3 classes from botocore.awsrequest import ( diff --git a/moto/backend_index.py b/moto/backend_index.py index 71b29417459a..5cc209a6df91 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -113,6 +113,7 @@ "https?://(?P[a-zA-Z0-9\\-_.]*)\\.?s3(.*)\\.amazonaws.com" ), ), + ("s3control", re.compile("https?://(.+)\\.s3-control\\.(.+)\\.amazonaws\\.com")), ("sagemaker", re.compile("https?://api.sagemaker\\.(.+)\\.amazonaws.com")), ("sdb", re.compile("https?://sdb\\.(.+)\\.amazonaws\\.com")), ("secretsmanager", re.compile("https?://secretsmanager\\.(.+)\\.amazonaws\\.com")), diff --git a/moto/s3control/__init__.py b/moto/s3control/__init__.py new file mode 100644 index 000000000000..1ef58b93931d --- /dev/null +++ b/moto/s3control/__init__.py @@ -0,0 +1,5 @@ +"""s3control module initialization; sets value for base decorator.""" +from .models import s3control_backends +from ..core.models import base_decorator + +mock_s3control = base_decorator(s3control_backends) diff --git a/moto/s3control/exceptions.py b/moto/s3control/exceptions.py new file mode 100644 index 000000000000..f86402104d31 --- /dev/null +++ b/moto/s3control/exceptions.py @@ -0,0 +1,35 @@ +"""Exceptions raised by the s3control service.""" +from moto.s3.exceptions import S3ClientError + + +class NoSuchPublicAccessBlockConfiguration(S3ClientError): + code = 404 + + def __init__(self, *args, **kwargs): + super(NoSuchPublicAccessBlockConfiguration, self).__init__( + "NoSuchPublicAccessBlockConfiguration", + "The public access block configuration was not found", + *args, + **kwargs, + ) + + +class InvalidPublicAccessBlockConfiguration(S3ClientError): + code = 400 + + def __init__(self, *args, **kwargs): + super(InvalidPublicAccessBlockConfiguration, self).__init__( + "InvalidRequest", + "Must specify at least one configuration.", + *args, + **kwargs, + ) + + +class WrongPublicAccessBlockAccountIdError(S3ClientError): + code = 403 + + def __init__(self): + super(WrongPublicAccessBlockAccountIdError, self).__init__( + "AccessDenied", "Access Denied" + ) diff --git a/moto/s3control/models.py b/moto/s3control/models.py new file mode 100644 index 000000000000..718bdeb3f6c7 --- /dev/null +++ b/moto/s3control/models.py @@ -0,0 +1,98 @@ +"""S3ControlBackend class with methods for supported APIs.""" +from collections import defaultdict + +from boto3 import Session + +from moto.core import BaseBackend, BaseModel +from moto.s3control.exceptions import ( + NoSuchPublicAccessBlockConfiguration, + WrongPublicAccessBlockAccountIdError, +) + + +class PublicAccessBlock(BaseModel): + def __init__( + self, + block_public_acls=False, + ignore_public_acls=False, + block_public_policy=False, + restrict_public_buckets=False, + ): + # The boto XML appears to expect these values to exist as lowercase strings... + self.block_public_acls = block_public_acls or "false" + self.ignore_public_acls = ignore_public_acls or "false" + self.block_public_policy = block_public_policy or "false" + self.restrict_public_buckets = restrict_public_buckets or "false" + + def to_config_dict(self): + # Need to make the string values booleans for Config: + return { + "blockPublicAcls": convert_str_to_bool(self.block_public_acls), + "ignorePublicAcls": convert_str_to_bool(self.ignore_public_acls), + "blockPublicPolicy": convert_str_to_bool(self.block_public_policy), + "restrictPublicBuckets": convert_str_to_bool(self.restrict_public_buckets), + } + + +def convert_str_to_bool(item): + """Converts a boolean string to a boolean value""" + if isinstance(item, str): + return item.lower() == "true" + + return False + + +class S3ControlBackend(BaseBackend): + """Implementation of S3Control APIs.""" + + def __init__(self, region_name): + self.initialized = defaultdict() + self.region_name = region_name + self.public_access_block_configuration = defaultdict() + + def reset(self): + """Re-initialize all attributes for this instance.""" + region_name = self.region_name + self.__dict__ = {} + self.__init__(region_name) + + @staticmethod + def get_public_access_block(account_id): + if account_id not in s3control_backends: + raise WrongPublicAccessBlockAccountIdError() + s3control_backend = s3control_backends[account_id] + if not s3control_backend.initialized or s3control_backend is None: + raise NoSuchPublicAccessBlockConfiguration() + return s3control_backend.public_access_block_configuration + + def put_public_access_block(self, account_id, public_access_block_configuration): + if account_id not in s3control_backends: + s3control_backends[account_id] = S3ControlBackend(self.region_name) + s3control_backend = s3control_backends[account_id] + else: + s3control_backend = s3control_backends[account_id] + s3control_backend.initialized = True + s3control_backend.public_access_block_configuration = PublicAccessBlock( + public_access_block_configuration.get("BlockPublicAcls"), + public_access_block_configuration.get("IgnorePublicAcls"), + public_access_block_configuration.get("BlockPublicPolicy"), + public_access_block_configuration.get("RestrictPublicBuckets"), + ) + + @staticmethod + def delete_public_access_block(account_id): + s3control_backend = s3control_backends[account_id] + s3control_backend.initialized = False + + +s3control_backends = {} +for available_region in Session().get_available_regions("s3control"): + s3control_backends[available_region] = S3ControlBackend(available_region) +for available_region in Session().get_available_regions( + "s3control", partition_name="aws-us-gov" +): + s3control_backends[available_region] = S3ControlBackend(available_region) +for available_region in Session().get_available_regions( + "s3control", partition_name="aws-cn" +): + s3control_backends[available_region] = S3ControlBackend(available_region) diff --git a/moto/s3control/responses.py b/moto/s3control/responses.py new file mode 100644 index 000000000000..4bf85d43d01e --- /dev/null +++ b/moto/s3control/responses.py @@ -0,0 +1,61 @@ +"""Handles incoming s3control requests, invokes methods, returns responses.""" +import json + +import xmltodict + +from moto.core.responses import BaseResponse +from .models import s3control_backends + + +class S3ControlResponse(BaseResponse): + SERVICE_NAME = "s3control" + """Handler for S3Control requests and responses.""" + + @property + def s3control_backend(self): + """Return backend instance specific for this region.""" + return s3control_backends[self.region] + + # add methods from here + + def get_public_access_block(self): + account_id = self.headers["x-amz-account-id"] + public_access_block_configuration = self.s3control_backend.get_public_access_block( + account_id=account_id, + ) + template = self.response_template(GET_PUBLIC_ACCESS_BLOCK_TEMPLATE) + return template.render( + BlockPublicAcls=public_access_block_configuration.block_public_acls, + IgnorePublicAcls=public_access_block_configuration.ignore_public_acls, + BlockPublicPolicy=public_access_block_configuration.block_public_policy, + RestrictPublicBuckets=public_access_block_configuration.restrict_public_buckets, + ) + + def put_public_access_block(self): + account_id = self.headers["x-amz-account-id"] + pab_config = self._parse_pab_config(self.body) + self.s3control_backend.put_public_access_block( + account_id, pab_config["PublicAccessBlockConfiguration"] + ) + return json.dumps({}) + + def delete_public_access_block(self): + account_id = self.headers["x-amz-account-id"] + self.s3control_backend.delete_public_access_block(account_id=account_id,) + return json.dumps({}) + + def _parse_pab_config(self, body): + parsed_xml = xmltodict.parse(body) + parsed_xml["PublicAccessBlockConfiguration"].pop("@xmlns", None) + + return parsed_xml + + +GET_PUBLIC_ACCESS_BLOCK_TEMPLATE = """ + + {{ BlockPublicAcls }} + {{ IgnorePublicAcls }} + {{ BlockPublicPolicy }} + {{ RestrictPublicBuckets }} + +""" diff --git a/moto/s3control/urls.py b/moto/s3control/urls.py new file mode 100644 index 000000000000..8e428f3ede27 --- /dev/null +++ b/moto/s3control/urls.py @@ -0,0 +1,10 @@ +"""s3control base URL and path.""" +from .responses import S3ControlResponse + +url_bases = [ + r"https?://(.+)\.s3-control\.(.+)\.amazonaws\.com", +] + +url_paths = { + "{0}/v20180820/configuration/publicAccessBlock$": S3ControlResponse.dispatch +} diff --git a/tests/test_s3control/__init__.py b/tests/test_s3control/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/test_s3control/test_s3control.py b/tests/test_s3control/test_s3control.py new file mode 100644 index 000000000000..509de4c7f5e3 --- /dev/null +++ b/tests/test_s3control/test_s3control.py @@ -0,0 +1,105 @@ +"""Unit tests for s3control-supported APIs.""" +import logging + +import boto3 +import pytest +import sure # noqa # pylint: disable=unused-import +from botocore.exceptions import ClientError + +from moto import mock_s3control, mock_organizations + +logger = logging.getLogger(__name__) + + +# See our Development Tips on writing tests for hints on how to write good tests: +# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html + + +def standard_organization_with_account(account_name="mock-account"): + client = boto3.client("organizations", region_name="eu-central-1") + client.create_organization(FeatureSet="ALL") + create_status = client.create_account( + AccountName=account_name, Email="mock@email.com" + )["CreateAccountStatus"] + return create_status["AccountId"] + + +@mock_organizations +@mock_s3control +def test_get_public_access_block(): + account_id = standard_organization_with_account() + client = boto3.client("s3control", region_name="eu-west-1") + client.put_public_access_block( + AccountId=account_id, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": True, + "IgnorePublicAcls": True, + "BlockPublicPolicy": False, + "RestrictPublicBuckets": False, + }, + ) + response = client.get_public_access_block(AccountId=account_id) + response["PublicAccessBlockConfiguration"].should.have.key("BlockPublicAcls").equal( + True + ) + response["PublicAccessBlockConfiguration"].should.have.key( + "IgnorePublicAcls" + ).equal(True) + response["PublicAccessBlockConfiguration"].should.have.key( + "BlockPublicPolicy" + ).equal(False) + response["PublicAccessBlockConfiguration"].should.have.key( + "RestrictPublicBuckets" + ).equal(False) + + client.put_public_access_block( + AccountId=account_id, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": True, + "IgnorePublicAcls": True, + "BlockPublicPolicy": True, + "RestrictPublicBuckets": True, + }, + ) + + response = client.get_public_access_block(AccountId=account_id) + response["PublicAccessBlockConfiguration"].should.have.key("BlockPublicAcls").equal( + True + ) + response["PublicAccessBlockConfiguration"].should.have.key( + "IgnorePublicAcls" + ).equal(True) + response["PublicAccessBlockConfiguration"].should.have.key( + "BlockPublicPolicy" + ).equal(True) + response["PublicAccessBlockConfiguration"].should.have.key( + "RestrictPublicBuckets" + ).equal(True) + + +@mock_organizations +@mock_s3control +def test_delete_public_access_block(): + account_id = standard_organization_with_account() + client = boto3.client("s3control", region_name="eu-west-1") + with pytest.raises(ClientError) as excinfo: + client.get_public_access_block(AccountId=account_id) + assert ( + "An error occurred (AccessDenied) when calling the GetPublicAccessBlock operation" + in str(excinfo.value) + ) + client.put_public_access_block( + AccountId=account_id, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": True, + "IgnorePublicAcls": True, + "BlockPublicPolicy": True, + "RestrictPublicBuckets": True, + }, + ) + client.get_public_access_block(AccountId=account_id) + client.delete_public_access_block(AccountId=account_id) + + with pytest.raises(ClientError) as excinfo: + client.get_public_access_block(AccountId=account_id) + assert "The public access block configuration was not found" in str(excinfo.value)