From 6733947a8c1def85b6271fbb62ad7e378a49e4d7 Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Thu, 24 Feb 2022 19:07:54 -0100 Subject: [PATCH] S3Control - Access Points --- moto/s3control/exceptions.py | 44 ++++ moto/s3control/models.py | 81 ++++++- moto/s3control/responses.py | 214 ++++++++++++++++-- moto/s3control/urls.py | 7 +- tests/terraform-tests.failures.txt | 6 +- tests/terraform-tests.success.txt | 5 +- .../test_s3control_access_points.py | 119 ++++++++++ .../test_s3control_accesspoint_policy.py | 116 ++++++++++ 8 files changed, 569 insertions(+), 23 deletions(-) create mode 100644 moto/s3control/exceptions.py create mode 100644 tests/test_s3control/test_s3control_access_points.py create mode 100644 tests/test_s3control/test_s3control_accesspoint_policy.py diff --git a/moto/s3control/exceptions.py b/moto/s3control/exceptions.py new file mode 100644 index 000000000..8e051b300 --- /dev/null +++ b/moto/s3control/exceptions.py @@ -0,0 +1,44 @@ +"""Exceptions raised by the s3control service.""" +from moto.core.exceptions import RESTError + + +ERROR_WITH_ACCESS_POINT_NAME = """{% extends 'wrapped_single_error' %} +{% block extra %}{{ name }}{% endblock %} +""" + + +ERROR_WITH_ACCESS_POINT_POLICY = """{% extends 'wrapped_single_error' %} +{% block extra %}{{ name }}{% endblock %} +""" + + +class S3ControlError(RESTError): + def __init__(self, *args, **kwargs): + kwargs.setdefault("template", "single_error") + super().__init__(*args, **kwargs) + + +class AccessPointNotFound(S3ControlError): + code = 404 + + def __init__(self, name, **kwargs): + kwargs.setdefault("template", "ap_not_found") + kwargs["name"] = name + self.templates["ap_not_found"] = ERROR_WITH_ACCESS_POINT_NAME + super().__init__( + "NoSuchAccessPoint", "The specified accesspoint does not exist", **kwargs + ) + + +class AccessPointPolicyNotFound(S3ControlError): + code = 404 + + def __init__(self, name, **kwargs): + kwargs.setdefault("template", "apf_not_found") + kwargs["name"] = name + self.templates["apf_not_found"] = ERROR_WITH_ACCESS_POINT_POLICY + super().__init__( + "NoSuchAccessPointPolicy", + "The specified accesspoint policy does not exist", + **kwargs + ) diff --git a/moto/s3control/models.py b/moto/s3control/models.py index d978213ce..003e05af0 100644 --- a/moto/s3control/models.py +++ b/moto/s3control/models.py @@ -1,4 +1,7 @@ -from moto.core import ACCOUNT_ID, BaseBackend +from collections import defaultdict +from datetime import datetime +from moto.core import ACCOUNT_ID, BaseBackend, BaseModel +from moto.core.utils import get_random_hex from moto.s3.exceptions import ( WrongPublicAccessBlockAccountIdError, NoSuchPublicAccessBlockConfiguration, @@ -6,6 +9,38 @@ from moto.s3.exceptions import ( ) from moto.s3.models import PublicAccessBlock +from .exceptions import AccessPointNotFound, AccessPointPolicyNotFound + + +class AccessPoint(BaseModel): + def __init__( + self, name, bucket, vpc_configuration, public_access_block_configuration + ): + self.name = name + self.alias = f"{name}-{get_random_hex(34)}-s3alias" + self.bucket = bucket + self.created = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f") + self.arn = f"arn:aws:s3:us-east-1:{ACCOUNT_ID}:accesspoint/{name}" + self.policy = None + self.network_origin = "VPC" if vpc_configuration else "Internet" + self.vpc_id = (vpc_configuration or {}).get("VpcId") + pubc = public_access_block_configuration or {} + self.pubc = { + "BlockPublicAcls": pubc.get("BlockPublicAcls", "true"), + "IgnorePublicAcls": pubc.get("IgnorePublicAcls", "true"), + "BlockPublicPolicy": pubc.get("BlockPublicPolicy", "true"), + "RestrictPublicBuckets": pubc.get("RestrictPublicBuckets", "true"), + } + + def delete_policy(self): + self.policy = None + + def set_policy(self, policy): + self.policy = policy + + def has_policy(self): + return self.policy is not None + class S3ControlBackend(BaseBackend): """ @@ -19,6 +54,7 @@ class S3ControlBackend(BaseBackend): def __init__(self, region_name=None): self.region_name = region_name self.public_access_block = None + self.access_points = defaultdict(dict) def reset(self): region_name = self.region_name @@ -57,5 +93,48 @@ class S3ControlBackend(BaseBackend): pub_block_config.get("RestrictPublicBuckets"), ) + def create_access_point( + self, + account_id, + name, + bucket, + vpc_configuration, + public_access_block_configuration, + ): + access_point = AccessPoint( + name, bucket, vpc_configuration, public_access_block_configuration + ) + self.access_points[account_id][name] = access_point + return access_point + + def delete_access_point(self, account_id, name): + self.access_points[account_id].pop(name, None) + + def get_access_point(self, account_id, name): + if name not in self.access_points[account_id]: + raise AccessPointNotFound(name) + return self.access_points[account_id][name] + + def create_access_point_policy(self, account_id, name, policy): + access_point = self.get_access_point(account_id, name) + access_point.set_policy(policy) + + def get_access_point_policy(self, account_id, name): + access_point = self.get_access_point(account_id, name) + if access_point.has_policy(): + return access_point.policy + raise AccessPointPolicyNotFound(name) + + def delete_access_point_policy(self, account_id, name): + access_point = self.get_access_point(account_id, name) + access_point.delete_policy() + + def get_access_point_policy_status(self, account_id, name): + """ + We assume the policy status is always public + """ + self.get_access_point_policy(account_id, name) + return True + s3control_backend = S3ControlBackend() diff --git a/moto/s3control/responses.py b/moto/s3control/responses.py index a073e2268..6cdec5a89 100644 --- a/moto/s3control/responses.py +++ b/moto/s3control/responses.py @@ -1,31 +1,39 @@ import json import xmltodict +from functools import wraps from moto.core.responses import BaseResponse from moto.core.utils import amzn_request_id from moto.s3.exceptions import S3ClientError from moto.s3.responses import S3_PUBLIC_ACCESS_BLOCK_CONFIGURATION +from .exceptions import S3ControlError from .models import s3control_backend -class S3ControlResponse(BaseResponse): - @classmethod - def public_access_block(cls, request, full_url, headers): - response_instance = S3ControlResponse() +def error_handler(f): + @wraps(f) + def _wrapper(*args, **kwargs): try: - return response_instance._public_access_block(request) + return f(*args, **kwargs) + except S3ControlError as e: + return e.code, e.get_headers(), e.get_body() + + return _wrapper + + +class S3ControlResponse(BaseResponse): + @amzn_request_id + def public_access_block(self, request, full_url, headers): + try: + if request.method == "GET": + return self.get_public_access_block(request) + elif request.method == "PUT": + return self.put_public_access_block(request) + elif request.method == "DELETE": + return self.delete_public_access_block(request) except S3ClientError as err: return err.code, {}, err.description - @amzn_request_id - def _public_access_block(self, request): - if request.method == "GET": - return self.get_public_access_block(request) - elif request.method == "PUT": - return self.put_public_access_block(request) - elif request.method == "DELETE": - return self.delete_public_access_block(request) - def get_public_access_block(self, request): account_id = request.headers.get("x-amz-account-id") public_block_config = s3control_backend.get_public_access_block( @@ -53,3 +61,181 @@ class S3ControlResponse(BaseResponse): parsed_xml["PublicAccessBlockConfiguration"].pop("@xmlns", None) return parsed_xml + + @error_handler + def access_point(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "PUT": + return self.create_access_point(full_url) + if request.method == "GET": + return self.get_access_point(full_url) + if request.method == "DELETE": + return self.delete_access_point(full_url) + + @error_handler + def access_point_policy(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "PUT": + return self.create_access_point_policy(full_url) + if request.method == "GET": + return self.get_access_point_policy(full_url) + if request.method == "DELETE": + return self.delete_access_point_policy(full_url) + + @error_handler + def access_point_policy_status(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "PUT": + return self.create_access_point(full_url) + if request.method == "GET": + return self.get_access_point_policy_status(full_url) + + def create_access_point(self, full_url): + account_id, name = self._get_accountid_and_name_from_accesspoint(full_url) + params = xmltodict.parse(self.body)["CreateAccessPointRequest"] + bucket = params["Bucket"] + vpc_configuration = params.get("VpcConfiguration") + public_access_block_configuration = params.get("PublicAccessBlockConfiguration") + access_point = s3control_backend.create_access_point( + account_id=account_id, + name=name, + bucket=bucket, + vpc_configuration=vpc_configuration, + public_access_block_configuration=public_access_block_configuration, + ) + template = self.response_template(CREATE_ACCESS_POINT_TEMPLATE) + return 200, {}, template.render(access_point=access_point) + + def get_access_point(self, full_url): + account_id, name = self._get_accountid_and_name_from_accesspoint(full_url) + + access_point = s3control_backend.get_access_point( + account_id=account_id, name=name, + ) + template = self.response_template(GET_ACCESS_POINT_TEMPLATE) + return 200, {}, template.render(access_point=access_point) + + def delete_access_point(self, full_url): + account_id, name = self._get_accountid_and_name_from_accesspoint(full_url) + s3control_backend.delete_access_point( + account_id=account_id, name=name, + ) + return 204, {}, "" + + def create_access_point_policy(self, full_url): + account_id, name = self._get_accountid_and_name_from_policy(full_url) + params = xmltodict.parse(self.body) + policy = params["PutAccessPointPolicyRequest"]["Policy"] + s3control_backend.create_access_point_policy(account_id, name, policy) + return 200, {}, "" + + def get_access_point_policy(self, full_url): + account_id, name = self._get_accountid_and_name_from_policy(full_url) + policy = s3control_backend.get_access_point_policy(account_id, name) + template = self.response_template(GET_ACCESS_POINT_POLICY_TEMPLATE) + return 200, {}, template.render(policy=policy) + + def delete_access_point_policy(self, full_url): + account_id, name = self._get_accountid_and_name_from_policy(full_url) + s3control_backend.delete_access_point_policy( + account_id=account_id, name=name, + ) + return 204, {}, "" + + def get_access_point_policy_status(self, full_url): + account_id, name = self._get_accountid_and_name_from_policy(full_url) + s3control_backend.get_access_point_policy_status(account_id, name) + template = self.response_template(GET_ACCESS_POINT_POLICY_STATUS_TEMPLATE) + return 200, {}, template.render() + + def _get_accountid_and_name_from_accesspoint(self, full_url): + url = full_url + if full_url.startswith("http"): + url = full_url.split("://")[1] + account_id = url.split(".")[0] + name = url.split("v20180820/accesspoint/")[-1] + return account_id, name + + def _get_accountid_and_name_from_policy(self, full_url): + url = full_url + if full_url.startswith("http"): + url = full_url.split("://")[1] + account_id = url.split(".")[0] + name = self.path.split("/")[-2] + return account_id, name + + +S3ControlResponseInstance = S3ControlResponse() + + +CREATE_ACCESS_POINT_TEMPLATE = """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + {{ access_point.name }} + {{ access_point.arn }} + +""" + + +GET_ACCESS_POINT_TEMPLATE = """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + {{ access_point.name }} + {{ access_point.bucket }} + {{ access_point.network_origin }} + {% if access_point.vpc_id %} + + {{ access_point.vpc_id }} + + {% endif %} + + {{ access_point.pubc["BlockPublicAcls"] }} + {{ access_point.pubc["IgnorePublicAcls"] }} + {{ access_point.pubc["BlockPublicPolicy"] }} + {{ access_point.pubc["RestrictPublicBuckets"] }} + + {{ access_point.created }} + {{ access_point.alias }} + {{ access_point.arn }} + + + ipv4 + s3-accesspoint.us-east-1.amazonaws.com + + + fips + s3-accesspoint-fips.us-east-1.amazonaws.com + + + fips_dualstack + s3-accesspoint-fips.dualstack.us-east-1.amazonaws.com + + + dualstack + s3-accesspoint.dualstack.us-east-1.amazonaws.com + + + +""" + + +GET_ACCESS_POINT_POLICY_TEMPLATE = """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + {{ policy }} + +""" + + +GET_ACCESS_POINT_POLICY_STATUS_TEMPLATE = """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + + true + + +""" diff --git a/moto/s3control/urls.py b/moto/s3control/urls.py index 14e5e66a5..b12246f3f 100644 --- a/moto/s3control/urls.py +++ b/moto/s3control/urls.py @@ -1,5 +1,5 @@ """s3control base URL and path.""" -from .responses import S3ControlResponse +from .responses import S3ControlResponseInstance url_bases = [ r"https?://([0-9]+)\.s3-control\.(.+)\.amazonaws\.com", @@ -7,5 +7,8 @@ url_bases = [ url_paths = { - "{0}/v20180820/configuration/publicAccessBlock$": S3ControlResponse.public_access_block, + "{0}/v20180820/configuration/publicAccessBlock$": S3ControlResponseInstance.public_access_block, + "{0}/v20180820/accesspoint/(?P[\w_:%-]+)$": S3ControlResponseInstance.access_point, + "{0}/v20180820/accesspoint/(?P[\w_:%-]+)/policy$": S3ControlResponseInstance.access_point_policy, + "{0}/v20180820/accesspoint/(?P[\w_:%-]+)/policyStatus$": S3ControlResponseInstance.access_point_policy_status, } diff --git a/tests/terraform-tests.failures.txt b/tests/terraform-tests.failures.txt index 92378672e..93c990561 100644 --- a/tests/terraform-tests.failures.txt +++ b/tests/terraform-tests.failures.txt @@ -9,8 +9,4 @@ TestAccDataSourceAwsNetworkInterface_CarrierIPAssociation TestAccAWSRouteTable_IPv4_To_LocalGateway TestAccAWSRouteTable_IPv4_To_VpcEndpoint TestAccAWSRouteTable_VpcClassicLink -TestAccAWSS3BucketObject_NonVersioned -TestAccAWSS3BucketObject_ignoreTags -TestAccAWSS3BucketObject_updatesWithVersioningViaAccessPoint -TestAccAWSS3BucketObject_updates -TestAccAWSS3BucketObject_updatesWithVersioning \ No newline at end of file +TestAccAWSS3BucketObject_updatesWithVersioningViaAccessPoint \ No newline at end of file diff --git a/tests/terraform-tests.success.txt b/tests/terraform-tests.success.txt index 8dbe512bf..b4480733e 100644 --- a/tests/terraform-tests.success.txt +++ b/tests/terraform-tests.success.txt @@ -120,7 +120,10 @@ TestAccAWSENI_Tags TestAccAWSENI_basic TestAccAWSENI_IPv6 TestAccAWSENI_disappears -TestAccAWSS3BucketObject_ +TestAccAWSS3BucketObject +TestAccAWSS3BucketPolicy +TestAccAWSS3AccessPoint +TestAccAWSS3BucketPublicAccessBlock TestAccAWSS3ObjectCopy TestAccAWSIAMPolicy_ TestAccAWSIAMGroup_ diff --git a/tests/test_s3control/test_s3control_access_points.py b/tests/test_s3control/test_s3control_access_points.py new file mode 100644 index 000000000..2b49d2670 --- /dev/null +++ b/tests/test_s3control/test_s3control_access_points.py @@ -0,0 +1,119 @@ +import boto3 +import pytest +import sure # noqa # pylint: disable=unused-import + +from botocore.client import ClientError +from moto import mock_s3control +from moto.core import ACCOUNT_ID + + +@mock_s3control +def test_create_access_point(): + client = boto3.client("s3control", region_name="eu-west-1") + resp = client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + resp.should.have.key("AccessPointArn") + resp.should.have.key("Alias").equals("ap_name") + + +@mock_s3control +def test_get_unknown_access_point(): + client = boto3.client("s3control", region_name="ap-southeast-1") + + with pytest.raises(ClientError) as exc: + client.get_access_point(AccountId="111111111111", Name="ap_name") + err = exc.value.response["Error"] + err["Code"].should.equal("NoSuchAccessPoint") + err["Message"].should.equal("The specified accesspoint does not exist") + err["AccessPointName"].should.equal("ap_name") + + +@mock_s3control +def test_get_access_point_minimal(): + client = boto3.client("s3control", region_name="ap-southeast-1") + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + resp = client.get_access_point(AccountId="111111111111", Name="ap_name") + + resp.should.have.key("Name").equals("ap_name") + resp.should.have.key("Bucket").equals("mybucket") + resp.should.have.key("NetworkOrigin").equals("Internet") + resp.should.have.key("PublicAccessBlockConfiguration").equals( + { + "BlockPublicAcls": True, + "IgnorePublicAcls": True, + "BlockPublicPolicy": True, + "RestrictPublicBuckets": True, + } + ) + resp.should.have.key("CreationDate") + resp.should.have.key("Alias").match("ap_name-[a-z0-9]+-s3alias") + resp.should.have.key("AccessPointArn").equals( + f"arn:aws:s3:us-east-1:{ACCOUNT_ID}:accesspoint/ap_name" + ) + resp.should.have.key("Endpoints") + + resp["Endpoints"].should.have.key("ipv4").equals( + "s3-accesspoint.us-east-1.amazonaws.com" + ) + resp["Endpoints"].should.have.key("fips").equals( + "s3-accesspoint-fips.us-east-1.amazonaws.com" + ) + resp["Endpoints"].should.have.key("fips_dualstack").equals( + "s3-accesspoint-fips.dualstack.us-east-1.amazonaws.com" + ) + resp["Endpoints"].should.have.key("dualstack").equals( + "s3-accesspoint.dualstack.us-east-1.amazonaws.com" + ) + + +@mock_s3control +def test_get_access_point_full(): + client = boto3.client("s3control", region_name="ap-southeast-1") + client.create_access_point( + AccountId="111111111111", + Name="ap_name", + Bucket="mybucket", + VpcConfiguration={"VpcId": "sth"}, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": False, + "IgnorePublicAcls": False, + "BlockPublicPolicy": False, + "RestrictPublicBuckets": False, + }, + ) + + resp = client.get_access_point(AccountId="111111111111", Name="ap_name") + + resp.should.have.key("Name").equals("ap_name") + resp.should.have.key("Bucket").equals("mybucket") + resp.should.have.key("NetworkOrigin").equals("VPC") + resp.should.have.key("VpcConfiguration").equals({"VpcId": "sth"}) + resp.should.have.key("PublicAccessBlockConfiguration").equals( + { + "BlockPublicAcls": False, + "IgnorePublicAcls": False, + "BlockPublicPolicy": False, + "RestrictPublicBuckets": False, + } + ) + + +@mock_s3control +def test_delete_access_point(): + client = boto3.client("s3control", region_name="ap-southeast-1") + + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + client.delete_access_point(AccountId="111111111111", Name="ap_name") + + with pytest.raises(ClientError) as exc: + client.get_access_point(AccountId="111111111111", Name="ap_name") + err = exc.value.response["Error"] + err["Code"].should.equal("NoSuchAccessPoint") diff --git a/tests/test_s3control/test_s3control_accesspoint_policy.py b/tests/test_s3control/test_s3control_accesspoint_policy.py new file mode 100644 index 000000000..13dbb2d18 --- /dev/null +++ b/tests/test_s3control/test_s3control_accesspoint_policy.py @@ -0,0 +1,116 @@ +import boto3 +import pytest +import sure # noqa # pylint: disable=unused-import + +from botocore.client import ClientError +from moto import mock_s3control + + +@mock_s3control +def test_get_access_point_policy(): + client = boto3.client("s3control", region_name="us-west-2") + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + policy = """{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": "s3:GetObjectTagging", + "Resource": "arn:aws:s3:us-east-1:123456789012:accesspoint/mybucket/object/*", + "Principal": { + "AWS": "*" + } + } + ] +}""" + client.put_access_point_policy( + AccountId="111111111111", Name="ap_name", Policy=policy + ) + + resp = client.get_access_point_policy(AccountId="111111111111", Name="ap_name") + resp.should.have.key("Policy").equals(policy) + + +@mock_s3control +def test_get_unknown_access_point_policy(): + client = boto3.client("s3control", region_name="ap-southeast-1") + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + with pytest.raises(ClientError) as exc: + client.get_access_point_policy(AccountId="111111111111", Name="ap_name") + err = exc.value.response["Error"] + err["Code"].should.equal("NoSuchAccessPointPolicy") + err["Message"].should.equal("The specified accesspoint policy does not exist") + err["AccessPointName"].should.equal("ap_name") + + +@mock_s3control +def test_get_access_point_policy_status(): + client = boto3.client("s3control", region_name="us-west-2") + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + policy = """{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": "s3:GetObjectTagging", + "Resource": "arn:aws:s3:us-east-1:123456789012:accesspoint/mybucket/object/*", + "Principal": { + "AWS": "*" + } + } + ] +}""" + client.put_access_point_policy( + AccountId="111111111111", Name="ap_name", Policy=policy + ) + + resp = client.get_access_point_policy_status( + AccountId="111111111111", Name="ap_name" + ) + resp.should.have.key("PolicyStatus").equals({"IsPublic": True}) + + +@mock_s3control +def test_delete_access_point_policy(): + client = boto3.client("s3control", region_name="us-west-2") + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + policy = """some json policy""" + client.put_access_point_policy( + AccountId="111111111111", Name="ap_name", Policy=policy + ) + + client.delete_access_point_policy(AccountId="111111111111", Name="ap_name") + + with pytest.raises(ClientError) as exc: + client.get_access_point_policy(AccountId="111111111111", Name="ap_name") + err = exc.value.response["Error"] + err["Code"].should.equal("NoSuchAccessPointPolicy") + + +@mock_s3control +def test_get_unknown_access_point_policy_status(): + client = boto3.client("s3control", region_name="ap-southeast-1") + client.create_access_point( + AccountId="111111111111", Name="ap_name", Bucket="mybucket", + ) + + with pytest.raises(ClientError) as exc: + client.get_access_point_policy_status(AccountId="111111111111", Name="ap_name") + err = exc.value.response["Error"] + err["Code"].should.equal("NoSuchAccessPointPolicy") + err["Message"].should.equal("The specified accesspoint policy does not exist") + err["AccessPointName"].should.equal("ap_name")