IAM - Implement ServiceLinkedRoles (#5089)

This commit is contained in:
Bert Blommers 2022-05-03 09:44:47 +00:00 committed by GitHub
parent 578de3d47f
commit 76fe578d95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 250 additions and 86 deletions

View File

@ -2903,7 +2903,7 @@
## iam ## iam
<details> <details>
<summary>71% implemented</summary> <summary>73% implemented</summary>
- [ ] add_client_id_to_open_id_connect_provider - [ ] add_client_id_to_open_id_connect_provider
- [X] add_role_to_instance_profile - [X] add_role_to_instance_profile
@ -2922,7 +2922,7 @@
- [X] create_policy_version - [X] create_policy_version
- [X] create_role - [X] create_role
- [X] create_saml_provider - [X] create_saml_provider
- [ ] create_service_linked_role - [X] create_service_linked_role
- [ ] create_service_specific_credential - [ ] create_service_specific_credential
- [X] create_user - [X] create_user
- [X] create_virtual_mfa_device - [X] create_virtual_mfa_device
@ -2942,7 +2942,7 @@
- [X] delete_role_policy - [X] delete_role_policy
- [X] delete_saml_provider - [X] delete_saml_provider
- [X] delete_server_certificate - [X] delete_server_certificate
- [ ] delete_service_linked_role - [X] delete_service_linked_role
- [ ] delete_service_specific_credential - [ ] delete_service_specific_credential
- [X] delete_signing_certificate - [X] delete_signing_certificate
- [X] delete_ssh_public_key - [X] delete_ssh_public_key
@ -2978,7 +2978,7 @@
- [X] get_server_certificate - [X] get_server_certificate
- [ ] get_service_last_accessed_details - [ ] get_service_last_accessed_details
- [ ] get_service_last_accessed_details_with_entities - [ ] 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_ssh_public_key
- [X] get_user - [X] get_user
- [X] get_user_policy - [X] get_user_policy

View File

@ -42,7 +42,7 @@ iam
- [X] create_policy_version - [X] create_policy_version
- [X] create_role - [X] create_role
- [X] create_saml_provider - [X] create_saml_provider
- [ ] create_service_linked_role - [X] create_service_linked_role
- [ ] create_service_specific_credential - [ ] create_service_specific_credential
- [X] create_user - [X] create_user
- [X] create_virtual_mfa_device - [X] create_virtual_mfa_device
@ -64,7 +64,7 @@ iam
- [X] delete_role_policy - [X] delete_role_policy
- [X] delete_saml_provider - [X] delete_saml_provider
- [X] delete_server_certificate - [X] delete_server_certificate
- [ ] delete_service_linked_role - [X] delete_service_linked_role
- [ ] delete_service_specific_credential - [ ] delete_service_specific_credential
- [X] delete_signing_certificate - [X] delete_signing_certificate
- [X] delete_ssh_public_key - [X] delete_ssh_public_key
@ -106,7 +106,11 @@ iam
- [X] get_server_certificate - [X] get_server_certificate
- [ ] get_service_last_accessed_details - [ ] get_service_last_accessed_details
- [ ] get_service_last_accessed_details_with_entities - [ ] 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_ssh_public_key
- [X] get_user - [X] get_user
- [X] get_user_policy - [X] get_user_policy

View File

@ -4,6 +4,7 @@ import os
import random import random
import string import string
import sys import sys
import uuid
from datetime import datetime from datetime import datetime
import json import json
import re import re
@ -12,6 +13,7 @@ import time
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from jinja2 import Template
from urllib import parse from urllib import parse
from moto.core.exceptions import RESTError from moto.core.exceptions import RESTError
from moto.core import BaseBackend, BaseModel, ACCOUNT_ID, CloudFormationModel from moto.core import BaseBackend, BaseModel, ACCOUNT_ID, CloudFormationModel
@ -47,6 +49,15 @@ from .utils import (
from ..utilities.tagging_service import TaggingService 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): class MFADevice(object):
"""MFA Device class.""" """MFA Device class."""
@ -556,6 +567,7 @@ class Role(CloudFormationModel):
description, description,
tags, tags,
max_session_duration, max_session_duration,
linked_service=None,
): ):
self.id = role_id self.id = role_id
self.name = name self.name = name
@ -568,6 +580,7 @@ class Role(CloudFormationModel):
self.description = description self.description = description
self.permissions_boundary = permissions_boundary self.permissions_boundary = permissions_boundary
self.max_session_duration = max_session_duration self.max_session_duration = max_session_duration
self._linked_service = linked_service
@property @property
def created_iso_8601(self): def created_iso_8601(self):
@ -622,6 +635,8 @@ class Role(CloudFormationModel):
@property @property
def arn(self): 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) return "arn:aws:iam::{0}:role{1}{2}".format(ACCOUNT_ID, self.path, self.name)
def to_config_dict(self): def to_config_dict(self):
@ -722,6 +737,41 @@ class Role(CloudFormationModel):
return html.escape(self.description or "") return html.escape(self.description or "")
def to_xml(self):
template = Template(
"""<Role>
<Path>{{ role.path }}</Path>
<Arn>{{ role.arn }}</Arn>
<RoleName>{{ role.name }}</RoleName>
<AssumeRolePolicyDocument>{{ role.assume_role_policy_document }}</AssumeRolePolicyDocument>
{% if role.description is not none %}
<Description>{{ role.description_escaped }}</Description>
{% endif %}
<CreateDate>{{ role.created_iso_8601 }}</CreateDate>
<RoleId>{{ role.id }}</RoleId>
{% if role.max_session_duration %}
<MaxSessionDuration>{{ role.max_session_duration }}</MaxSessionDuration>
{% endif %}
{% if role.permissions_boundary %}
<PermissionsBoundary>
<PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>
<PermissionsBoundaryArn>{{ role.permissions_boundary }}</PermissionsBoundaryArn>
</PermissionsBoundary>
{% endif %}
{% if role.tags %}
<Tags>
{% for tag in role.get_tags() %}
<member>
<Key>{{ tag['Key'] }}</Key>
<Value>{{ tag['Value'] }}</Value>
</member>
{% endfor %}
</Tags>
{% endif %}
</Role>"""
)
return template.render(role=self)
class InstanceProfile(CloudFormationModel): class InstanceProfile(CloudFormationModel):
def __init__(self, instance_profile_id, name, path, roles, tags=None): def __init__(self, instance_profile_id, name, path, roles, tags=None):
@ -1707,6 +1757,7 @@ class IAMBackend(BaseBackend):
description, description,
tags, tags,
max_session_duration, max_session_duration,
linked_service=None,
): ):
role_id = random_resource_id() role_id = random_resource_id()
if permissions_boundary and not self.policy_arn_regex.match( if permissions_boundary and not self.policy_arn_regex.match(
@ -1733,6 +1784,7 @@ class IAMBackend(BaseBackend):
description, description,
clean_tags, clean_tags,
max_session_duration, max_session_duration,
linked_service=linked_service,
) )
self.roles[role_id] = role self.roles[role_id] = role
return role return role
@ -2813,5 +2865,51 @@ class IAMBackend(BaseBackend):
self.tagger.untag_resource_using_names(user.arn, tag_keys) 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() iam_backend = IAMBackend()

View File

@ -1088,6 +1088,32 @@ class IamResponse(BaseResponse):
template = self.response_template(UNTAG_USER_TEMPLATE) template = self.response_template(UNTAG_USER_TEMPLATE)
return template.render() 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 = """<ListEntitiesForPolicyResponse> LIST_ENTITIES_FOR_POLICY_TEMPLATE = """<ListEntitiesForPolicyResponse>
<ListEntitiesForPolicyResult> <ListEntitiesForPolicyResult>
@ -1399,34 +1425,7 @@ GET_INSTANCE_PROFILE_TEMPLATE = """<GetInstanceProfileResponse xmlns="https://ia
CREATE_ROLE_TEMPLATE = """<CreateRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"> CREATE_ROLE_TEMPLATE = """<CreateRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<CreateRoleResult> <CreateRoleResult>
<Role> {{ role.to_xml() }}
<Path>{{ role.path }}</Path>
<Arn>{{ role.arn }}</Arn>
<RoleName>{{ role.name }}</RoleName>
<AssumeRolePolicyDocument>{{ role.assume_role_policy_document }}</AssumeRolePolicyDocument>
{% if role.description is not none %}
<Description>{{ role.description_escaped }}</Description>
{% endif %}
<CreateDate>{{ role.created_iso_8601 }}</CreateDate>
<RoleId>{{ role.id }}</RoleId>
<MaxSessionDuration>{{ role.max_session_duration }}</MaxSessionDuration>
{% if role.permissions_boundary %}
<PermissionsBoundary>
<PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>
<PermissionsBoundaryArn>{{ role.permissions_boundary }}</PermissionsBoundaryArn>
</PermissionsBoundary>
{% endif %}
{% if role.tags %}
<Tags>
{% for tag in role.get_tags() %}
<member>
<Key>{{ tag['Key'] }}</Key>
<Value>{{ tag['Value'] }}</Value>
</member>
{% endfor %}
</Tags>
{% endif %}
</Role>
</CreateRoleResult> </CreateRoleResult>
<ResponseMetadata> <ResponseMetadata>
<RequestId>4a93ceee-9966-11e1-b624-b1aEXAMPLE7c</RequestId> <RequestId>4a93ceee-9966-11e1-b624-b1aEXAMPLE7c</RequestId>
@ -1444,6 +1443,33 @@ GET_ROLE_POLICY_TEMPLATE = """<GetRolePolicyResponse xmlns="https://iam.amazonaw
</ResponseMetadata> </ResponseMetadata>
</GetRolePolicyResponse>""" </GetRolePolicyResponse>"""
CREATE_SERVICE_LINKED_ROLE_TEMPLATE = """<CreateServiceLinkedRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<CreateServiceLinkedRoleResult>
{{ role.to_xml() }}
</CreateServiceLinkedRoleResult>
<ResponseMetadata>
<RequestId>4a93ceee-9966-11e1-b624-b1aEXAMPLE7c</RequestId>
</ResponseMetadata>
</CreateServiceLinkedRoleResponse>"""
DELETE_SERVICE_LINKED_ROLE_TEMPLATE = """<DeleteServiceLinkedRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<DeleteServiceLinkedRoleResult>
<DeletionTaskId>{{ deletion_task_id }}</DeletionTaskId>
</DeleteServiceLinkedRoleResult>
<ResponseMetadata>
<RequestId>4a93ceee-9966-11e1-b624-b1aEXAMPLE7c</RequestId>
</ResponseMetadata>
</DeleteServiceLinkedRoleResponse>"""
GET_SERVICE_LINKED_ROLE_DELETION_STATUS_TEMPLATE = """<GetServiceLinkedRoleDeletionStatusResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<GetServiceLinkedRoleDeletionStatusResult>
<Status>SUCCEEDED</Status>
</GetServiceLinkedRoleDeletionStatusResult>
<ResponseMetadata>
<RequestId>4a93ceee-9966-11e1-b624-b1aEXAMPLE7c</RequestId>
</ResponseMetadata>
</GetServiceLinkedRoleDeletionStatusResponse>"""
UPDATE_ROLE_TEMPLATE = """<UpdateRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"> UPDATE_ROLE_TEMPLATE = """<UpdateRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<UpdateRoleResult> <UpdateRoleResult>
</UpdateRoleResult> </UpdateRoleResult>
@ -1454,28 +1480,7 @@ UPDATE_ROLE_TEMPLATE = """<UpdateRoleResponse xmlns="https://iam.amazonaws.com/d
UPDATE_ROLE_DESCRIPTION_TEMPLATE = """<UpdateRoleDescriptionResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"> UPDATE_ROLE_DESCRIPTION_TEMPLATE = """<UpdateRoleDescriptionResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<UpdateRoleDescriptionResult> <UpdateRoleDescriptionResult>
<Role> {{ role.to_xml() }}
<Path>{{ role.path }}</Path>
<Arn>{{ role.arn }}</Arn>
<RoleName>{{ role.name }}</RoleName>
<AssumeRolePolicyDocument>{{ role.assume_role_policy_document }}</AssumeRolePolicyDocument>
{% if role.description is not none %}
<Description>{{ role.description_escaped }}</Description>
{% endif %}
<CreateDate>{{ role.created_iso_8601 }}</CreateDate>
<RoleId>{{ role.id }}</RoleId>
<MaxSessionDuration>{{ role.max_session_duration }}</MaxSessionDuration>
{% if role.tags %}
<Tags>
{% for tag in role.get_tags() %}
<member>
<Key>{{ tag['Key'] }}</Key>
<Value>{{ tag['Value'] }}</Value>
</member>
{% endfor %}
</Tags>
{% endif %}
</Role>
</UpdateRoleDescriptionResult> </UpdateRoleDescriptionResult>
<ResponseMetadata> <ResponseMetadata>
<RequestId>df37e965-9967-11e1-a4c3-270EXAMPLE04</RequestId> <RequestId>df37e965-9967-11e1-a4c3-270EXAMPLE04</RequestId>
@ -1484,34 +1489,7 @@ UPDATE_ROLE_DESCRIPTION_TEMPLATE = """<UpdateRoleDescriptionResponse xmlns="http
GET_ROLE_TEMPLATE = """<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"> GET_ROLE_TEMPLATE = """<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<GetRoleResult> <GetRoleResult>
<Role> {{ role.to_xml() }}
<Path>{{ role.path }}</Path>
<Arn>{{ role.arn }}</Arn>
<RoleName>{{ role.name }}</RoleName>
<AssumeRolePolicyDocument>{{ role.assume_role_policy_document }}</AssumeRolePolicyDocument>
{% if role.description is not none %}
<Description>{{ role.description_escaped }}</Description>
{% endif %}
<CreateDate>{{ role.created_iso_8601 }}</CreateDate>
<RoleId>{{ role.id }}</RoleId>
<MaxSessionDuration>{{ role.max_session_duration }}</MaxSessionDuration>
{% if role.permissions_boundary %}
<PermissionsBoundary>
<PermissionsBoundaryType>PermissionsBoundaryPolicy</PermissionsBoundaryType>
<PermissionsBoundaryArn>{{ role.permissions_boundary }}</PermissionsBoundaryArn>
</PermissionsBoundary>
{% endif %}
{% if role.tags %}
<Tags>
{% for tag in role.get_tags() %}
<member>
<Key>{{ tag['Key'] }}</Key>
<Value>{{ tag['Value'] }}</Value>
</member>
{% endfor %}
</Tags>
{% endif %}
</Role>
</GetRoleResult> </GetRoleResult>
<ResponseMetadata> <ResponseMetadata>
<RequestId>df37e965-9967-11e1-a4c3-270EXAMPLE04</RequestId> <RequestId>df37e965-9967-11e1-a4c3-270EXAMPLE04</RequestId>

View File

@ -25,7 +25,7 @@ index 51e5d1c9c7..057446ae1d 100644
Target: []string{iam.DeletionTaskStatusTypeSucceeded}, Target: []string{iam.DeletionTaskStatusTypeSucceeded},
Refresh: statusDeleteServiceLinkedRole(conn, deletionTaskID), Refresh: statusDeleteServiceLinkedRole(conn, deletionTaskID),
- Timeout: 5 * time.Minute, - Timeout: 5 * time.Minute,
+ Timeout: 5 * time.Second, + Timeout: 15 * time.Second,
Delay: 10 * time.Second, Delay: 10 * time.Second,
} }

View File

@ -91,6 +91,7 @@ iam:
- TestAccIAMRolePolicy_ - TestAccIAMRolePolicy_
- TestAccIAMRolePolicyAttachment_ - TestAccIAMRolePolicyAttachment_
- TestAccIAMSessionContextDataSource_ - TestAccIAMSessionContextDataSource_
- TestAccIAMServiceLinkedRole
- TestAccIAMUserDataSource_ - TestAccIAMUserDataSource_
- TestAccIAMUserPolicy_ - TestAccIAMUserPolicy_
- TestAccIAMUserPolicyAttachment_ - TestAccIAMUserPolicyAttachment_
@ -100,6 +101,10 @@ iot:
- TestAccIoTEndpointDataSource - TestAccIoTEndpointDataSource
kms: kms:
- TestAccKMSAlias - TestAccKMSAlias
- TestAccKMSKey_Policy_basic
- TestAccKMSKey_Policy_iamRole
- TestAccKMSKey_Policy_iamRoleOrder
- TestAccKMSKey_Policy_iamServiceLinkedRole
- TestAccKMSSecretDataSource - TestAccKMSSecretDataSource
- TestAccKMSSecretsDataSource - TestAccKMSSecretsDataSource
meta: meta:

View File

@ -4407,3 +4407,82 @@ def test_untag_user_error_unknown_user_name():
ex.response["Error"]["Message"].should.equal( ex.response["Error"]["Message"].should.equal(
"The user with name {} cannot be found.".format(name) "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")