From 76fe578d9520f63bb32c5e98df1b3031072e75ea Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Tue, 3 May 2022 09:44:47 +0000 Subject: [PATCH] IAM - Implement ServiceLinkedRoles (#5089) --- IMPLEMENTATION_COVERAGE.md | 8 +- docs/docs/services/iam.rst | 10 +- moto/iam/models.py | 98 +++++++++++++ moto/iam/responses.py | 134 ++++++++---------- .../etc/0003-Patch-IAM-wait-times.patch | 2 +- .../terraform-tests.success.txt | 5 + tests/test_iam/test_iam.py | 79 +++++++++++ 7 files changed, 250 insertions(+), 86 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 97ce00316..303eeddaf 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -2903,7 +2903,7 @@ ## iam
-71% implemented +73% implemented - [ ] add_client_id_to_open_id_connect_provider - [X] add_role_to_instance_profile @@ -2922,7 +2922,7 @@ - [X] create_policy_version - [X] create_role - [X] create_saml_provider -- [ ] create_service_linked_role +- [X] create_service_linked_role - [ ] create_service_specific_credential - [X] create_user - [X] create_virtual_mfa_device @@ -2942,7 +2942,7 @@ - [X] delete_role_policy - [X] delete_saml_provider - [X] delete_server_certificate -- [ ] delete_service_linked_role +- [X] delete_service_linked_role - [ ] delete_service_specific_credential - [X] delete_signing_certificate - [X] delete_ssh_public_key @@ -2978,7 +2978,7 @@ - [X] get_server_certificate - [ ] get_service_last_accessed_details - [ ] get_service_last_accessed_details_with_entities -- [ ] get_service_linked_role_deletion_status +- [X] get_service_linked_role_deletion_status - [X] get_ssh_public_key - [X] get_user - [X] get_user_policy diff --git a/docs/docs/services/iam.rst b/docs/docs/services/iam.rst index 3fb888065..87b13f623 100644 --- a/docs/docs/services/iam.rst +++ b/docs/docs/services/iam.rst @@ -42,7 +42,7 @@ iam - [X] create_policy_version - [X] create_role - [X] create_saml_provider -- [ ] create_service_linked_role +- [X] create_service_linked_role - [ ] create_service_specific_credential - [X] create_user - [X] create_virtual_mfa_device @@ -64,7 +64,7 @@ iam - [X] delete_role_policy - [X] delete_saml_provider - [X] delete_server_certificate -- [ ] delete_service_linked_role +- [X] delete_service_linked_role - [ ] delete_service_specific_credential - [X] delete_signing_certificate - [X] delete_ssh_public_key @@ -106,7 +106,11 @@ iam - [X] get_server_certificate - [ ] get_service_last_accessed_details - [ ] get_service_last_accessed_details_with_entities -- [ ] get_service_linked_role_deletion_status +- [X] get_service_linked_role_deletion_status + + This method always succeeds for now - we do not yet keep track of deletions + + - [X] get_ssh_public_key - [X] get_user - [X] get_user_policy diff --git a/moto/iam/models.py b/moto/iam/models.py index b8381d439..82e9f9246 100644 --- a/moto/iam/models.py +++ b/moto/iam/models.py @@ -4,6 +4,7 @@ import os import random import string import sys +import uuid from datetime import datetime import json import re @@ -12,6 +13,7 @@ import time from cryptography import x509 from cryptography.hazmat.backends import default_backend +from jinja2 import Template from urllib import parse from moto.core.exceptions import RESTError from moto.core import BaseBackend, BaseModel, ACCOUNT_ID, CloudFormationModel @@ -47,6 +49,15 @@ from .utils import ( from ..utilities.tagging_service import TaggingService +# Map to convert service names used in ServiceLinkedRoles +# The PascalCase should be used as part of the RoleName +SERVICE_NAME_CONVERSION = { + "autoscaling": "AutoScaling", + "application-autoscaling": "ApplicationAutoScaling", + "elasticbeanstalk": "ElasticBeanstalk", +} + + class MFADevice(object): """MFA Device class.""" @@ -556,6 +567,7 @@ class Role(CloudFormationModel): description, tags, max_session_duration, + linked_service=None, ): self.id = role_id self.name = name @@ -568,6 +580,7 @@ class Role(CloudFormationModel): self.description = description self.permissions_boundary = permissions_boundary self.max_session_duration = max_session_duration + self._linked_service = linked_service @property def created_iso_8601(self): @@ -622,6 +635,8 @@ class Role(CloudFormationModel): @property def arn(self): + if self._linked_service: + return f"arn:aws:iam::{ACCOUNT_ID}:role/aws-service-role/{self._linked_service}/{self.name}" return "arn:aws:iam::{0}:role{1}{2}".format(ACCOUNT_ID, self.path, self.name) def to_config_dict(self): @@ -722,6 +737,41 @@ class Role(CloudFormationModel): return html.escape(self.description or "") + def to_xml(self): + template = Template( + """ + {{ role.path }} + {{ role.arn }} + {{ role.name }} + {{ role.assume_role_policy_document }} + {% if role.description is not none %} + {{ role.description_escaped }} + {% endif %} + {{ role.created_iso_8601 }} + {{ role.id }} + {% if role.max_session_duration %} + {{ role.max_session_duration }} + {% endif %} + {% if role.permissions_boundary %} + + PermissionsBoundaryPolicy + {{ role.permissions_boundary }} + + {% endif %} + {% if role.tags %} + + {% for tag in role.get_tags() %} + + {{ tag['Key'] }} + {{ tag['Value'] }} + + {% endfor %} + + {% endif %} + """ + ) + return template.render(role=self) + class InstanceProfile(CloudFormationModel): def __init__(self, instance_profile_id, name, path, roles, tags=None): @@ -1707,6 +1757,7 @@ class IAMBackend(BaseBackend): description, tags, max_session_duration, + linked_service=None, ): role_id = random_resource_id() if permissions_boundary and not self.policy_arn_regex.match( @@ -1733,6 +1784,7 @@ class IAMBackend(BaseBackend): description, clean_tags, max_session_duration, + linked_service=linked_service, ) self.roles[role_id] = role return role @@ -2813,5 +2865,51 @@ class IAMBackend(BaseBackend): self.tagger.untag_resource_using_names(user.arn, tag_keys) + def create_service_linked_role(self, service_name, description, suffix): + # service.amazonaws.com -> Service + # some-thing.service.amazonaws.com -> Service_SomeThing + service = service_name.split(".")[-3] + prefix = service_name.split(".")[0] + if service != prefix: + prefix = "".join([x.capitalize() for x in prefix.split("-")]) + service = SERVICE_NAME_CONVERSION.get(service, service) + "_" + prefix + else: + service = SERVICE_NAME_CONVERSION.get(service, service) + role_name = f"AWSServiceRoleFor{service}" + if suffix: + role_name = role_name + f"_{suffix}" + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["sts:AssumeRole"], + "Effect": "Allow", + "Principal": {"Service": [service_name]}, + } + ], + } + path = f"/aws-service-role/{service_name}/" + return self.create_role( + role_name, + json.dumps(assume_role_policy_document), + path, + permissions_boundary=None, + description=description, + tags=[], + max_session_duration=None, + linked_service=service_name, + ) + + def delete_service_linked_role(self, role_name): + self.delete_role(role_name) + deletion_task_id = str(uuid.uuid4()) + return deletion_task_id + + def get_service_linked_role_deletion_status(self): + """ + This method always succeeds for now - we do not yet keep track of deletions + """ + return True + iam_backend = IAMBackend() diff --git a/moto/iam/responses.py b/moto/iam/responses.py index c28e3707e..eea1d9950 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -1088,6 +1088,32 @@ class IamResponse(BaseResponse): template = self.response_template(UNTAG_USER_TEMPLATE) return template.render() + def create_service_linked_role(self): + service_name = self._get_param("AWSServiceName") + description = self._get_param("Description") + suffix = self._get_param("CustomSuffix") + + role = iam_backend.create_service_linked_role(service_name, description, suffix) + + template = self.response_template(CREATE_SERVICE_LINKED_ROLE_TEMPLATE) + return template.render(role=role) + + def delete_service_linked_role(self): + role_name = self._get_param("RoleName") + + deletion_task_id = iam_backend.delete_service_linked_role(role_name) + + template = self.response_template(DELETE_SERVICE_LINKED_ROLE_TEMPLATE) + return template.render(deletion_task_id=deletion_task_id) + + def get_service_linked_role_deletion_status(self): + iam_backend.get_service_linked_role_deletion_status() + + template = self.response_template( + GET_SERVICE_LINKED_ROLE_DELETION_STATUS_TEMPLATE + ) + return template.render() + LIST_ENTITIES_FOR_POLICY_TEMPLATE = """ @@ -1399,34 +1425,7 @@ GET_INSTANCE_PROFILE_TEMPLATE = """ - - {{ role.path }} - {{ role.arn }} - {{ role.name }} - {{ role.assume_role_policy_document }} - {% if role.description is not none %} - {{ role.description_escaped }} - {% endif %} - {{ role.created_iso_8601 }} - {{ role.id }} - {{ role.max_session_duration }} - {% if role.permissions_boundary %} - - PermissionsBoundaryPolicy - {{ role.permissions_boundary }} - - {% endif %} - {% if role.tags %} - - {% for tag in role.get_tags() %} - - {{ tag['Key'] }} - {{ tag['Value'] }} - - {% endfor %} - - {% endif %} - + {{ role.to_xml() }} 4a93ceee-9966-11e1-b624-b1aEXAMPLE7c @@ -1444,6 +1443,33 @@ GET_ROLE_POLICY_TEMPLATE = """ + + {{ role.to_xml() }} + + + 4a93ceee-9966-11e1-b624-b1aEXAMPLE7c + +""" + +DELETE_SERVICE_LINKED_ROLE_TEMPLATE = """ + + {{ deletion_task_id }} + + + 4a93ceee-9966-11e1-b624-b1aEXAMPLE7c + +""" + +GET_SERVICE_LINKED_ROLE_DELETION_STATUS_TEMPLATE = """ + + SUCCEEDED + + + 4a93ceee-9966-11e1-b624-b1aEXAMPLE7c + +""" + UPDATE_ROLE_TEMPLATE = """ @@ -1454,28 +1480,7 @@ UPDATE_ROLE_TEMPLATE = """ - - {{ role.path }} - {{ role.arn }} - {{ role.name }} - {{ role.assume_role_policy_document }} - {% if role.description is not none %} - {{ role.description_escaped }} - {% endif %} - {{ role.created_iso_8601 }} - {{ role.id }} - {{ role.max_session_duration }} - {% if role.tags %} - - {% for tag in role.get_tags() %} - - {{ tag['Key'] }} - {{ tag['Value'] }} - - {% endfor %} - - {% endif %} - + {{ role.to_xml() }} df37e965-9967-11e1-a4c3-270EXAMPLE04 @@ -1484,34 +1489,7 @@ UPDATE_ROLE_DESCRIPTION_TEMPLATE = """ - - {{ role.path }} - {{ role.arn }} - {{ role.name }} - {{ role.assume_role_policy_document }} - {% if role.description is not none %} - {{ role.description_escaped }} - {% endif %} - {{ role.created_iso_8601 }} - {{ role.id }} - {{ role.max_session_duration }} - {% if role.permissions_boundary %} - - PermissionsBoundaryPolicy - {{ role.permissions_boundary }} - - {% endif %} - {% if role.tags %} - - {% for tag in role.get_tags() %} - - {{ tag['Key'] }} - {{ tag['Value'] }} - - {% endfor %} - - {% endif %} - + {{ role.to_xml() }} df37e965-9967-11e1-a4c3-270EXAMPLE04 diff --git a/tests/terraformtests/etc/0003-Patch-IAM-wait-times.patch b/tests/terraformtests/etc/0003-Patch-IAM-wait-times.patch index 41f8f096c..5ead30f20 100644 --- a/tests/terraformtests/etc/0003-Patch-IAM-wait-times.patch +++ b/tests/terraformtests/etc/0003-Patch-IAM-wait-times.patch @@ -25,7 +25,7 @@ index 51e5d1c9c7..057446ae1d 100644 Target: []string{iam.DeletionTaskStatusTypeSucceeded}, Refresh: statusDeleteServiceLinkedRole(conn, deletionTaskID), - Timeout: 5 * time.Minute, -+ Timeout: 5 * time.Second, ++ Timeout: 15 * time.Second, Delay: 10 * time.Second, } diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt index 4ba891f07..b1724ddf3 100644 --- a/tests/terraformtests/terraform-tests.success.txt +++ b/tests/terraformtests/terraform-tests.success.txt @@ -91,6 +91,7 @@ iam: - TestAccIAMRolePolicy_ - TestAccIAMRolePolicyAttachment_ - TestAccIAMSessionContextDataSource_ + - TestAccIAMServiceLinkedRole - TestAccIAMUserDataSource_ - TestAccIAMUserPolicy_ - TestAccIAMUserPolicyAttachment_ @@ -100,6 +101,10 @@ iot: - TestAccIoTEndpointDataSource kms: - TestAccKMSAlias + - TestAccKMSKey_Policy_basic + - TestAccKMSKey_Policy_iamRole + - TestAccKMSKey_Policy_iamRoleOrder + - TestAccKMSKey_Policy_iamServiceLinkedRole - TestAccKMSSecretDataSource - TestAccKMSSecretsDataSource meta: diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py index 0f0237272..f013137c7 100644 --- a/tests/test_iam/test_iam.py +++ b/tests/test_iam/test_iam.py @@ -4407,3 +4407,82 @@ def test_untag_user_error_unknown_user_name(): ex.response["Error"]["Message"].should.equal( "The user with name {} cannot be found.".format(name) ) + + +@mock_iam +@pytest.mark.parametrize( + "service,cased", + [ + ("autoscaling", "AutoScaling"), + ("elasticbeanstalk", "ElasticBeanstalk"), + ( + "custom-resource.application-autoscaling", + "ApplicationAutoScaling_CustomResource", + ), + ("other", "other"), + ], +) +def test_create_service_linked_role(service, cased): + client = boto3.client("iam", region_name="eu-central-1") + + resp = client.create_service_linked_role( + AWSServiceName=f"{service}.amazonaws.com", Description="desc" + )["Role"] + + resp.should.have.key("RoleName").equals(f"AWSServiceRoleFor{cased}") + + +@mock_iam +def test_create_service_linked_role__with_suffix(): + client = boto3.client("iam", region_name="eu-central-1") + + resp = client.create_service_linked_role( + AWSServiceName="autoscaling.amazonaws.com", + CustomSuffix="suf", + Description="desc", + )["Role"] + + resp.should.have.key("RoleName").match("_suf$") + resp.should.have.key("Description").equals("desc") + resp.should.have.key("AssumeRolePolicyDocument") + policy_doc = resp["AssumeRolePolicyDocument"] + policy_doc.should.have.key("Statement").equals( + [ + { + "Action": ["sts:AssumeRole"], + "Effect": "Allow", + "Principal": {"Service": ["autoscaling.amazonaws.com"]}, + } + ] + ) + + +@mock_iam +def test_delete_service_linked_role(): + client = boto3.client("iam", region_name="eu-central-1") + + role_name = client.create_service_linked_role( + AWSServiceName="autoscaling.amazonaws.com", + CustomSuffix="suf", + Description="desc", + )["Role"]["RoleName"] + + # Role exists + client.get_role(RoleName=role_name) + + # Delete role + resp = client.delete_service_linked_role(RoleName=role_name) + resp.should.have.key("DeletionTaskId") + + # Role deletion should be successful + resp = client.get_service_linked_role_deletion_status( + DeletionTaskId=resp["DeletionTaskId"] + ) + resp.should.have.key("Status").equals("SUCCEEDED") + + # Role no longer exists + with pytest.raises(ClientError) as ex: + client.get_role(RoleName=role_name) + err = ex.value.response["Error"] + err["Code"].should.equal("NoSuchEntity") + err["Message"].should.contain("not found")