Skip to content

Commit

Permalink
Add Cross Stack Policy to IAM Role (#562)
Browse files Browse the repository at this point in the history
  • Loading branch information
pc-alves authored and jmcs committed May 13, 2019
1 parent 7943fff commit 4e8fc4b
Show file tree
Hide file tree
Showing 9 changed files with 467 additions and 17 deletions.
2 changes: 1 addition & 1 deletion senza/components/elastic_load_balancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def get_ssl_cert(subdomain, main_zone, ssl_cert, account_info: AccountArguments)
# the priority is acm_certificate first and iam_certificate second
certificates = (
acm_certificates + iam_certificates
) # type: List[Union[ACMCertificate, IAMServerCertificate]]
) # type: List[Union[ACMCertificate, IAMServerCertificate]] # noqa: F821
try:
certificate = certificates[0]
ssl_cert = certificate.arn
Expand Down
86 changes: 85 additions & 1 deletion senza/components/elastigroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
from senza.components.auto_scaling_group import normalize_network_threshold
from senza.components.taupage_auto_scaling_group import check_application_id, check_application_version, \
check_docker_image_exists, generate_user_data
from senza.utils import ensure_keys
from senza.utils import ensure_keys, CROSS_STACK_POLICY_NAME
from senza.spotinst import MissingSpotinstAccount
import senza.manaus.iam

ELASTIGROUP_RESOURCE_TYPE = 'Custom::elastigroup'
SPOTINST_LAMBDA_FORMATION_ARN = 'arn:aws:lambda:{}:178579023202:function:spotinst-cloudformation'
Expand All @@ -28,6 +29,88 @@
ELASTIGROUP_DEFAULT_PRODUCT = "Linux/UNIX"


def get_instance_profile_from_definition(definition, elastigroup_config):
launch_spec = elastigroup_config["compute"]["launchSpecification"]

if "iamRole" not in launch_spec:
return None

if "name" in launch_spec["iamRole"]:
if isinstance(launch_spec["iamRole"]["name"], dict):
instance_profile_id = launch_spec["iamRole"]["name"]["Ref"]
instance_profile = definition["Resources"].get(instance_profile_id, None)
if instance_profile is None:
raise click.UsageError("Instance Profile referenced is not present in Resources")

if instance_profile["Type"] != "AWS::IAM::InstanceProfile":
raise click.UsageError(
"Instance Profile references a Resource that is not of type 'AWS::IAM::InstanceProfile'")

return instance_profile

return None


def get_instance_profile_role(instance_profile, definition):
roles = instance_profile["Properties"]["Roles"]
if isinstance(roles[0], dict):
role_id = roles[0]["Ref"]
role = definition["Resources"].get(role_id, None)
if role is None:
raise click.UsageError("Instance Profile references a Role that is not present in Resources")

if role["Type"] != "AWS::IAM::Role":
raise click.UsageError("Instance Profile Role references a Resource that is not of type 'AWS::IAM::Role'")

return role

return None


def create_cross_stack_policy_document():
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudformation:SignalResource",
"cloudformation:DescribeStackResource"
],
"Resource": "*"
}
]
}


def find_or_create_cross_stack_policy():
return senza.manaus.iam.find_or_create_policy(policy_name=CROSS_STACK_POLICY_NAME,
policy_document=create_cross_stack_policy_document(),
description="Required permissions for EC2 instances created by "
"Spotinst to signal CloudFormation")


def patch_cross_stack_policy(definition, elastigroup_config):
"""
This function will make sure that the role used in the Instance Profile includes the Cross Stack API
requests policy, needed for Elastigroups to run as expected.
"""
instance_profile = get_instance_profile_from_definition(definition, elastigroup_config)
if instance_profile is None:
return

instance_profile_role = get_instance_profile_role(instance_profile, definition)
if instance_profile_role is None:
return

cross_stack_policy = find_or_create_cross_stack_policy()

role_properties = instance_profile_role["Properties"]
managed_policies_set = set(role_properties.get("ManagedPolicyArns", []))
managed_policies_set.add(cross_stack_policy["Arn"])
role_properties["ManagedPolicyArns"] = list(managed_policies_set)


def component_elastigroup(definition, configuration, args, info, force, account_info):
"""
This component creates a Spotinst Elastigroup CloudFormation custom resource template.
Expand Down Expand Up @@ -62,6 +145,7 @@ def component_elastigroup(definition, configuration, args, info, force, account_
extract_auto_scaling_rules(configuration, elastigroup_config)
extract_block_mappings(configuration, elastigroup_config)
extract_instance_profile(args, definition, configuration, elastigroup_config)
patch_cross_stack_policy(definition, elastigroup_config)
# cfn definition
access_token = _extract_spotinst_access_token(definition)
config_name = configuration["Name"]
Expand Down
40 changes: 40 additions & 0 deletions senza/manaus/iam.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Any, Dict, Iterator, Optional, Union

import boto3
import json
from botocore.exceptions import ClientError

from .boto_proxy import BotoClientProxy
Expand Down Expand Up @@ -161,3 +162,42 @@ def get_certificates(
continue

yield certificate


def _get_policy_by_name(policy_name, iam_client):
"""
This function goes through all the policies in the AWS account and return the first one matching the policy_name
input parameter
"""
paginator = iam_client.get_paginator("list_policies")

page_iterator = paginator.paginate()

for page in page_iterator:
if "Policies" in page:
for policy in page["Policies"]:
if policy["PolicyName"] == policy_name:
return policy

return None


def find_or_create_policy(policy_name, policy_document, description):
"""
This function will look for a policy name with `policy_name`.
If not found, it will create the policy using the provided `policy_name` and `policy_document`.
:return: Policy object
"""
iam_client = boto3.client("iam")

policy = _get_policy_by_name(policy_name, iam_client)
if policy is None:
response = iam_client.create_policy(
PolicyName=policy_name,
PolicyDocument=json.dumps(policy_document),
Description=description
)
policy = response["Policy"]

return policy
78 changes: 69 additions & 9 deletions senza/templates/_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from click import confirm
from clickclick import Action
from senza.aws import get_account_alias, get_account_id, get_security_group
from senza.utils import CROSS_STACK_POLICY_NAME
import senza.manaus.iam

from ..manaus.boto_proxy import BotoClientProxy

Expand Down Expand Up @@ -130,7 +132,7 @@ def get_mint_bucket_name(region: str):
return bucket_name


def get_iam_role_policy(application_id: str, bucket_name: str, region: str):
def create_mint_read_policy_document(application_id: str, bucket_name: str, region: str):
return {
"Version": "2012-10-17",
"Statement": [
Expand All @@ -146,6 +148,33 @@ def get_iam_role_policy(application_id: str, bucket_name: str, region: str):
}


def create_cross_stack_policy_document():
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudformation:SignalResource",
"cloudformation:DescribeStackResource"
],
"Resource": "*"
}
]
}


def check_cross_stack_policy(iam, role_name: str):
try:
iam.get_role_policy(
RoleName=role_name,
PolicyName=CROSS_STACK_POLICY_NAME
)
return True
except botocore.exceptions.ClientError:
return False


def check_iam_role(application_id: str, bucket_name: str, region: str):
role_name = "app-{}".format(application_id)
with Action("Checking IAM role {}..".format(role_name)):
Expand All @@ -167,6 +196,8 @@ def check_iam_role(application_id: str, bucket_name: str, region: str):
],
"Version": "2008-10-17",
}

create = False
if not exists:
create = confirm(
"IAM role {} does not exist. "
Expand All @@ -180,20 +211,49 @@ def check_iam_role(application_id: str, bucket_name: str, region: str):
AssumeRolePolicyDocument=json.dumps(assume_role_policy_document),
)

update_policy = bucket_name is not None and (
not exists
or confirm(
"IAM role {} already exists. ".format(role_name)
+ "Do you want Senza to overwrite the role policy?"
attach_mint_read_policy = bucket_name is not None and (
(not exists and create)
or (
exists and confirm(
"IAM role {} already exists. ".format(role_name)
+ "Do you want Senza to overwrite the role policy?"
)
)
)
if update_policy:
if attach_mint_read_policy:
with Action("Updating IAM role policy of {}..".format(role_name)):
policy = get_iam_role_policy(application_id, bucket_name, region)
mint_read_policy = create_mint_read_policy_document(application_id, bucket_name, region)
iam.put_role_policy(
RoleName=role_name,
PolicyName=role_name,
PolicyDocument=json.dumps(policy),
PolicyDocument=json.dumps(mint_read_policy),
)

attach_cross_stack_policy(exists, create, role_name, iam)


def find_or_create_cross_stack_policy():
return senza.manaus.iam.find_or_create_policy(policy_name=CROSS_STACK_POLICY_NAME,
policy_document=create_cross_stack_policy_document(),
description="Required permissions for EC2 instances created by "
"Spotinst to signal CloudFormation")


def attach_cross_stack_policy(pre_existing_role, role_created, role_name, iam_client):
if not pre_existing_role and not role_created:
return

cross_stack_policy_exists = False
if pre_existing_role:
cross_stack_policy_exists = check_cross_stack_policy(iam_client, role_name)

if role_created or not cross_stack_policy_exists:
with Action("Updating IAM role policy of {}..".format(role_name)):
policy = find_or_create_cross_stack_policy()

iam_client.attach_role_policy(
RoleName=role_name,
PolicyArn=policy["Arn"],
)


Expand Down
2 changes: 2 additions & 0 deletions senza/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import re
import pystache

CROSS_STACK_POLICY_NAME = "system-cf-notifications"


def named_value(dictionary):
"""
Expand Down
Loading

0 comments on commit 4e8fc4b

Please sign in to comment.