2
votes

I am writing an IAM role for a CI/CD user which deploys our Cloud Development Kit (CDK) app. The CDK app consists of lambda functions, Fargate etc. The problem is, that CDK does not allow me to specify all the roles it needs. Instead it creates some of then on its own.

Couple of examples:

  1. Each lambda function with log retention has another lambda created by CDK which sets log retention to the log group and log streams.
  2. CloudTrail event executing a step function needs a role with states:StartExecution permission.

CDK creates these roles automatically and also puts inline policies to them. Which forces me to give my CI/CD role permissions to create roles and attach policies. So if anybody gets access to the CI/CD user (for example if our GitHub credentials leak), the attacker could create new roles and give them admin permissions.

I tried creating all the roles myself in a separate stack and then using these roles in CDK app. But as I mentioned above (see the examples above), it's not possible everywhere...

I also tried IAM permission boundary for the deployer role, but I can't figure out how to limit permissions for iam:PutRolePolicy. CDK essentially does the following:

  1. iam:CreateRole
  2. iam:PutRolePolicy

According to AWS documentation, conditions are quite basic string comparisons. I need to be able to select, which actions are allowed in the policy document passed to iam:PutRolePolicy.

This is a sample of my permission boundary allowing the principal to create roles and put role policies. See the condition comment.

permission_boundary = aws_iam.ManagedPolicy(
    scope=self,
    id='DeployerPermissionBoundary',
    managed_policy_name='DeployerPermissionBoundary',
    statements=[
        aws_iam.PolicyStatement(
            actions=['iam:CreateRole'],
            effect=aws_iam.Effect.ALLOW,
            resources=[f'arn:aws:iam::{core.Aws.ACCOUNT_ID}:role/my-project-lambda-role']
        ),
        aws_iam.PolicyStatement(
            actions=['iam:PutRolePolicy'],
            effect=aws_iam.Effect.ALLOW,
            resources=[f'arn:aws:iam::{core.Aws.ACCOUNT_ID}:role/my-project-lambda-role'],
            conditions=Conditions([
                StringLike('RoleName', 'Required-role-name'),
                StringLike('PolicyName', 'Required-policy-name'),
                StringEquals('PolicyDocument', '') # I want to allow only specified actions like logs:CreateLogStream and logs:PutLogEvents
            ])
        )
    ]
)

deployer_role = aws_iam.Role(
    scope=self,
    id='DeployerRole',
    assumed_by=aws_iam.AccountRootPrincipal(),
    permissions_boundary=permission_boundary,
    inline_policies={
        'Deployer': aws_iam.PolicyDocument(
            statements=[
                aws_iam.PolicyStatement(
                    actions=['iam:PutRolePolicy'],
                    effect=aws_iam.Effect.ALLOW,
                    resources=[f'arn:aws:iam::{core.Aws.ACCOUNT_ID}:role/my-project-lambda-role']
                ),
                ...
                ...
            ]
        )
    }
)

What is the correct way of limiting the PutRolePolicy to selected actions only? I want to allow logs:CreateLogStream and logs:PutLogEvents and nothing else.

I've been fighting with this for quite some time and I don't want to fall back to giving out more permissions than necessary. Thanks everyone in advance!

3

3 Answers

2
votes

Here's a solution in Python for CDK 1.4.0 inspired by @matthewtapper's code on GitHub. This allows you to set permission boundary to all the roles in your stack.

Needless to say it's very ugly, since python CDK does not provide construct objects in aspects. We have to dig deep into JSII to resolve the objects. Hope it helps someone.

from jsii._reference_map import _refs
from jsii._utils import Singleton
import jsii

@jsii.implements(core.IAspect)
class PermissionBoundaryAspect:

    def __init__(self, permission_boundary: Union[aws_iam.ManagedPolicy, str]) -> None:
        """
        :param permission_boundary: Either aws_iam.ManagedPolicy object or managed policy's ARN as string
        """
        self.permission_boundary = permission_boundary

    def visit(self, construct_ref: core.IConstruct) -> None:
        """
        construct_ref only contains a string reference to an object. To get the actual object, we need to resolve it using JSII mapping.
        :param construct_ref: ObjRef object with string reference to the actual object.
        :return: None
        """
        kernel = Singleton._instances[jsii._kernel.Kernel]
        resolve = _refs.resolve(kernel, construct_ref)

        def _walk(obj):
            if isinstance(obj, aws_iam.Role):
                cfn_role = obj.node.find_child('Resource')
                policy_arn = self.permission_boundary if isinstance(self.permission_boundary, str) else self.permission_boundary.managed_policy_arn
                cfn_role.add_property_override('PermissionsBoundary', policy_arn)
            else:
                if hasattr(obj, 'permissions_node'):
                    for c in obj.permissions_node.children:
                        _walk(c)
                if obj.node.children:
                    for c in obj.node.children:
                        _walk(c)

        _walk(resolve)

Usage:

stack.node.apply_aspect(PermissionBoundaryAspect(managed_policy_arn))
0
votes

Here is the solution for CDK version 1.9.0 + with added and extra try_find_child() to prevent nested child errors on the node, also the stack.node.apply_aspect() method is depreceated by AWS, so there is a new usage implementation.

from aws_cdk import (
    aws_iam as iam,
    core,
)
import jsii
from jsii._reference_map import _refs
from jsii._utils import Singleton

from typing import Union


@jsii.implements(core.IAspect)
class PermissionBoundaryAspect:
    """
    This aspect finds all aws_iam.Role objects in a node (ie. CDK stack) and sets
    permission boundary to the given ARN.
    """

    def __init__(self, permission_boundary: Union[iam.ManagedPolicy, str]) -> None:
        """
        This initialization method sets the permission boundary attribute.

        :param permission_boundary: The provided permission boundary
        :type permission_boundary: iam.ManagedPolicy|str
        """
        self.permission_boundary = permission_boundary
        print(self.permission_boundary)

    def visit(self, construct_ref: core.IConstruct) -> None:
        """
        construct_ref only contains a string reference to an object.
        To get the actual object, we need to resolve it using JSII mapping.
        :param construct_ref: ObjRef object with string reference to the actual object.
        :return: None
        """
        if isinstance(construct_ref, jsii._kernel.ObjRef) and hasattr(
            construct_ref, "ref"
        ):
            kernel = Singleton._instances[
                jsii._kernel.Kernel
            ]  # The same object is available as: jsii.kernel
            resolve = _refs.resolve(kernel, construct_ref)
        else:
            resolve = construct_ref

        def _walk(obj):
            if obj.node.try_find_child("Resource") is not None:
                if isinstance(obj, iam.Role):
                    cfn_role = obj.node.find_child("Resource")
                    policy_arn = (
                        self.permission_boundary
                        if isinstance(self.permission_boundary, str)
                        else self.permission_boundary.managed_policy_arn
                    )
                    cfn_role.add_property_override("PermissionsBoundary", policy_arn)
            else:
                if hasattr(obj, "permissions_node"):
                    for c in obj.permissions_node.children:
                        _walk(c)
                if hasattr(obj, "node") and obj.node.children:
                    for c in obj.node.children:
                        _walk(c)

        _walk(resolve)

And the new implementation API for the stack is:

core.Aspects.of(stack).add(
    PermissionBoundaryAspect(
        f"arn:aws:iam::{target_environment.account}:policy/my-permissions-boundary"
    )
)
0
votes

Anyone still struggling in certain cases or wants a Java example:

@Slf4j
public class PermissionBoundaryRoleAspect implements IAspect {

private static final String BOUNDED_PATH = "/bounded/";

@Override
public void visit(final @NotNull IConstruct node) {

    node.getNode().findAll().stream().filter(iConstruct -> CfnResource.isCfnResource(iConstruct) && iConstruct.toString().contains("AWS::IAM::Role")).forEach(iConstruct -> {
        var resource = (CfnResource) iConstruct;
        resource.addPropertyOverride("PermissionsBoundary", "arn:aws:iam::xxx:policy/BoundedPermissionsPolicy");
        resource.addPropertyOverride("Path", BOUNDED_PATH);
    });

    if (node instanceof CfnInstanceProfile) {
        var instanceProfile = (CfnInstanceProfile) node;
        instanceProfile.setPath(BOUNDED_PATH);
    }
  }
}

Why I am doing it this way, is because I was faced with a case where not all Roles being created was of type CfnRole

In my case I had to create a CfnCloudFormationProvisionedProduct

This constructor had a weird way of creating Roles. Roles in this constructor is of type CfnResource and cannot be casted to "CfnRole"

Thus I am using iConstruct.toString().contains("AWS::IAM::Role") which works for every resource if its type AWS::IAM::Role and for any CfnRole