From bd627b65f7c53713fc5e2b0d6eea1e90ffb941f8 Mon Sep 17 00:00:00 2001 From: gruebel Date: Fri, 18 Oct 2019 17:29:15 +0200 Subject: [PATCH] Add iam.create_open_id_connect_provider --- IMPLEMENTATION_COVERAGE.md | 4 +- moto/iam/exceptions.py | 24 +++++++ moto/iam/models.py | 87 ++++++++++++++++++++++++- moto/iam/responses.py | 19 ++++++ tests/test_iam/test_iam.py | 130 +++++++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 4 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 4fd909d67..3bea66408 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3163,7 +3163,7 @@ - [ ] describe_events ## iam -55% implemented +56% implemented - [ ] add_client_id_to_open_id_connect_provider - [X] add_role_to_instance_profile - [X] add_user_to_group @@ -3176,7 +3176,7 @@ - [X] create_group - [X] create_instance_profile - [X] create_login_profile -- [ ] create_open_id_connect_provider +- [X] create_open_id_connect_provider - [X] create_policy - [X] create_policy_version - [X] create_role diff --git a/moto/iam/exceptions.py b/moto/iam/exceptions.py index ac08e0d88..0511fa144 100644 --- a/moto/iam/exceptions.py +++ b/moto/iam/exceptions.py @@ -93,3 +93,27 @@ class TooManyTags(RESTError): super(TooManyTags, self).__init__( 'ValidationError', "1 validation error detected: Value '{}' at '{}' failed to satisfy " "constraint: Member must have length less than or equal to 50.".format(tags, param)) + + +class EntityAlreadyExists(RESTError): + code = 409 + + def __init__(self): + super(EntityAlreadyExists, self).__init__( + 'EntityAlreadyExists', "Unknown") + + +class ValidationError(RESTError): + code = 400 + + def __init__(self, message): + super(ValidationError, self).__init__( + 'ValidationError', message) + + +class InvalidInput(RESTError): + code = 400 + + def __init__(self, message): + super(InvalidInput, self).__init__( + 'InvalidInput', message) diff --git a/moto/iam/models.py b/moto/iam/models.py index 506f2a942..2a7d185a5 100644 --- a/moto/iam/models.py +++ b/moto/iam/models.py @@ -7,6 +7,7 @@ import re from cryptography import x509 from cryptography.hazmat.backends import default_backend +from six.moves.urllib.parse import urlparse from moto.core.exceptions import RESTError from moto.core import BaseBackend, BaseModel @@ -14,8 +15,9 @@ from moto.core.utils import iso_8601_datetime_without_milliseconds, iso_8601_dat from moto.iam.policy_validation import IAMPolicyDocumentValidator from .aws_managed_policies import aws_managed_policies_data -from .exceptions import IAMNotFoundException, IAMConflictException, IAMReportNotPresentException, IAMLimitExceededException, \ - MalformedCertificate, DuplicateTags, TagKeyTooBig, InvalidTagCharacters, TooManyTags, TagValueTooBig +from .exceptions import (IAMNotFoundException, IAMConflictException, IAMReportNotPresentException, IAMLimitExceededException, + MalformedCertificate, DuplicateTags, TagKeyTooBig, InvalidTagCharacters, TooManyTags, TagValueTooBig, + EntityAlreadyExists, ValidationError, InvalidInput) from .utils import random_access_key, random_alphanumeric, random_resource_id, random_policy_id ACCOUNT_ID = 123456789012 @@ -93,6 +95,77 @@ class SAMLProvider(BaseModel): return "arn:aws:iam::{0}:saml-provider/{1}".format(ACCOUNT_ID, self.name) +class OpenIDConnectProvider(BaseModel): + def __init__(self, url, thumbprint_list, client_id_list=None): + self._errors = [] + self._validate(url, thumbprint_list, client_id_list) + + parsed_url = urlparse(url) + self.url = parsed_url.netloc + parsed_url.path + self.thumbprint_list = thumbprint_list + self.client_id_list = client_id_list + + @property + def arn(self): + return 'arn:aws:iam::{0}:oidc-provider/{1}'.format(ACCOUNT_ID, self.url) + + def _validate(self, url, thumbprint_list, client_id_list): + if any(len(client_id) > 255 for client_id in client_id_list): + self._errors.append(self._format_error( + key='clientIDList', + value=client_id_list, + constraint='Member must satisfy constraint: ' + '[Member must have length less than or equal to 255, ' + 'Member must have length greater than or equal to 1]', + )) + + if any(len(thumbprint) > 40 for thumbprint in thumbprint_list): + self._errors.append(self._format_error( + key='thumbprintList', + value=thumbprint_list, + constraint='Member must satisfy constraint: ' + '[Member must have length less than or equal to 40, ' + 'Member must have length greater than or equal to 40]', + )) + + if len(url) > 255: + self._errors.append(self._format_error( + key='url', + value=url, + constraint='Member must have length less than or equal to 255', + )) + + self._raise_errors() + + parsed_url = urlparse(url) + if not parsed_url.scheme or not parsed_url.netloc: + raise ValidationError('Invalid Open ID Connect Provider URL') + + if len(thumbprint_list) > 5: + raise InvalidInput('Thumbprint list must contain fewer than 5 entries.') + + if len(client_id_list) > 100: + raise IAMLimitExceededException('Cannot exceed quota for ClientIdsPerOpenIdConnectProvider: 100') + + def _format_error(self, key, value, constraint): + return 'Value "{value}" at "{key}" failed to satisfy constraint: {constraint}'.format( + constraint=constraint, + key=key, + value=value, + ) + + def _raise_errors(self): + if self._errors: + count = len(self._errors) + plural = "s" if len(self._errors) > 1 else "" + errors = "; ".join(self._errors) + self._errors = [] # reset collected errors + + raise ValidationError('{count} validation error{plural} detected: {errors}'.format( + count=count, plural=plural, errors=errors, + )) + + class PolicyVersion(object): def __init__(self, @@ -515,6 +588,7 @@ class IAMBackend(BaseBackend): self.managed_policies = self._init_managed_policies() self.account_aliases = [] self.saml_providers = {} + self.open_id_providers = {} self.policy_arn_regex = re.compile( r'^arn:aws:iam::[0-9]*:policy/.*$') super(IAMBackend, self).__init__() @@ -1264,5 +1338,14 @@ class IAMBackend(BaseBackend): return user return None + def create_open_id_connect_provider(self, url, thumbprint_list, client_id_list): + open_id_provider = OpenIDConnectProvider(url, thumbprint_list, client_id_list) + + if open_id_provider.arn in self.open_id_providers: + raise EntityAlreadyExists + + self.open_id_providers[open_id_provider.arn] = open_id_provider + return open_id_provider + iam_backend = IAMBackend() diff --git a/moto/iam/responses.py b/moto/iam/responses.py index 8e63c1075..a4ed38666 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -755,6 +755,15 @@ class IamResponse(BaseResponse): template = self.response_template(UNTAG_ROLE_TEMPLATE) return template.render() + def create_open_id_connect_provider(self): + open_id_provider_url = self._get_param('Url') + thumbprint_list = self._get_multi_param('ThumbprintList.member') + client_id_list = self._get_multi_param('ClientIDList.member') + open_id_provider = iam_backend.create_open_id_connect_provider(open_id_provider_url, thumbprint_list, client_id_list) + + template = self.response_template(CREATE_OPEN_ID_CONNECT_PROVIDER_TEMPLATE) + return template.render(open_id_provider=open_id_provider) + LIST_ENTITIES_FOR_POLICY_TEMPLATE = """ @@ -1974,3 +1983,13 @@ UNTAG_ROLE_TEMPLATE = """ + + {{ open_id_provider.arn }} + + + f248366a-4f64-11e4-aefa-bfd6aEXAMPLE + +""" diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py index 23846712d..deec15285 100644 --- a/tests/test_iam/test_iam.py +++ b/tests/test_iam/test_iam.py @@ -1565,3 +1565,133 @@ def test_create_role_with_permissions_boundary(): # Ensure the PermissionsBoundary is included in role listing as well conn.list_roles().get('Roles')[0].get('PermissionsBoundary').should.equal(expected) + + +@mock_iam +def test_create_open_id_connect_provider(): + client = boto3.client('iam', region_name='us-east-1') + response = client.create_open_id_connect_provider( + Url='https://example.com', + ThumbprintList=[] # even it is required to provide at least one thumbprint, AWS accepts an empty list + ) + + response['OpenIDConnectProviderArn'].should.equal( + 'arn:aws:iam::123456789012:oidc-provider/example.com' + ) + + response = client.create_open_id_connect_provider( + Url='http://example.org', + ThumbprintList=[ + 'b' * 40 + ], + ClientIDList=[ + 'b' + ] + ) + + response['OpenIDConnectProviderArn'].should.equal( + 'arn:aws:iam::123456789012:oidc-provider/example.org' + ) + + response = client.create_open_id_connect_provider( + Url='http://example.org/oidc', + ThumbprintList=[] + ) + + response['OpenIDConnectProviderArn'].should.equal( + 'arn:aws:iam::123456789012:oidc-provider/example.org/oidc' + ) + + response = client.create_open_id_connect_provider( + Url='http://example.org/oidc-query?test=true', + ThumbprintList=[] + ) + + response['OpenIDConnectProviderArn'].should.equal( + 'arn:aws:iam::123456789012:oidc-provider/example.org/oidc-query' + ) + + +@mock_iam +def test_create_open_id_connect_provider_errors(): + client = boto3.client('iam', region_name='us-east-1') + response = client.create_open_id_connect_provider( + Url='https://example.com', + ThumbprintList=[] + ) + open_id_arn = response['OpenIDConnectProviderArn'] + + client.create_open_id_connect_provider.when.called_with( + Url='https://example.com', + ThumbprintList=[] + ).should.throw( + ClientError, + 'Unknown' + ) + + client.create_open_id_connect_provider.when.called_with( + Url='example.org', + ThumbprintList=[] + ).should.throw( + ClientError, + 'Invalid Open ID Connect Provider URL' + ) + + client.create_open_id_connect_provider.when.called_with( + Url='example', + ThumbprintList=[] + ).should.throw( + ClientError, + 'Invalid Open ID Connect Provider URL' + ) + + client.create_open_id_connect_provider.when.called_with( + Url='http://example.org', + ThumbprintList=[ + 'a' * 40, + 'b' * 40, + 'c' * 40, + 'd' * 40, + 'e' * 40, + 'f' * 40, + ] + ).should.throw( + ClientError, + 'Thumbprint list must contain fewer than 5 entries.' + ) + + too_many_client_ids = ['{}'.format(i) for i in range(101)] + client.create_open_id_connect_provider.when.called_with( + Url='http://example.org', + ThumbprintList=[], + ClientIDList=too_many_client_ids + ).should.throw( + ClientError, + 'Cannot exceed quota for ClientIdsPerOpenIdConnectProvider: 100' + ) + + too_long_url = 'b' * 256 + too_long_thumbprint = 'b' * 41 + too_long_client_id = 'b' * 256 + client.create_open_id_connect_provider.when.called_with( + Url=too_long_url, + ThumbprintList=[ + too_long_thumbprint + ], + ClientIDList=[ + too_long_client_id + ] + ).should.throw( + ClientError, + '3 validation errors detected: ' + 'Value "{0}" at "clientIDList" failed to satisfy constraint: ' + 'Member must satisfy constraint: ' + '[Member must have length less than or equal to 255, ' + 'Member must have length greater than or equal to 1]; ' + 'Value "{1}" at "thumbprintList" failed to satisfy constraint: ' + 'Member must satisfy constraint: ' + '[Member must have length less than or equal to 40, ' + 'Member must have length greater than or equal to 40]; ' + 'Value "{2}" at "url" failed to satisfy constraint: ' + 'Member must have length less than or equal to 255'.format([too_long_client_id], [too_long_thumbprint], too_long_url) + )