Repository: cloud-custodian/cloud-custodian

Issue:
IAM Role action to attach a specified policy
Hi c7n folks.

I have a feature enhancement request for the c7n.resources.iam.py file. I can contribute a pull request with the changes, but want to find out if you have any concerns or requirements that I should be aware of before I do.

We would like to create an action which can automatically attach a specified IAM policy to roles discovered using the NoSpecificIamRoleManagedPolicy filter.

The use-case for this new action is a desire to have a specific policy attached to all IAM roles in an AWS account for security purposes.

My intention is to design the action to accept a policy arn or policy name; if the name is provided the arn will automatically be discovered.

A c7n policy using this new action, called `attach-specific-policy` might be structured like this:

```
policies:
  - name: find-roles-without-policy-and-attach
    description: looks for existing IAM roles which do not have a required policy attached and attaches it
    resource: iam-role
    filters:
      - and:
        - type: no-specific-managed-policy
          value: my-iam-policy
    actions:
      - type: attach-specific-policy
        value: my-iam-policy
```

Thanks for your time.


Discussion:
thanks for the detailed use case, sounds good to me. wrt to styling the action, we've been trying to move actions to cover both sides of boolean behaviors ie (add/remove -> set)

```
-type: set-policy
  state: attached
  name: my-iam-policy
```

so detaching would following just work by changing the state.
Sounds good, thanks @kapilt .

I'll let you know when the pull request is ready for review.

Resolving patch:
diff --git a/c7n/resources/iam.py b/c7n/resources/iam.py
--- a/c7n/resources/iam.py
+++ b/c7n/resources/iam.py
@@ -465,6 +465,58 @@ def process(self, resources, event=None):
         return []
 
 
+@Role.action_registry.register('set-policy')
+class SetPolicy(BaseAction):
+    """Set a specific IAM policy as attached or detached on a role.
+
+    You will identify the policy by its arn.
+
+    Returns a list of roles modified by the action.
+
+    For example, if you want to automatically attach a policy to all roles which don't have it...
+
+    :example:
+
+      .. code-block:: yaml
+
+        - name: iam-attach-role-policy
+          resource: iam-role
+          filters:
+            - type: no-specific-managed-policy
+              value: my-iam-policy
+          actions:
+            - type: set-policy
+              state: attached
+              arn: arn:aws:iam::123456789012:policy/my-iam-policy
+
+    """
+    schema = type_schema(
+        'set-policy',
+        state={'enum': ['attached', 'detached']},
+        arn={'type': 'string'},
+        required=['state', 'arn'])
+
+    permissions = ('iam:AttachRolePolicy', 'iam:DetachRolePolicy',)
+
+    def process(self, resources):
+        client = local_session(self.manager.session_factory).client('iam')
+        policy_arn = self.data['arn']
+        state = self.data['state']
+
+        for r in resources:
+            if state == 'attached':
+                client.attach_role_policy(
+                    RoleName=r['RoleName'],
+                    PolicyArn=policy_arn)
+            elif state == 'detached':
+                try:
+                    client.detach_role_policy(
+                        RoleName=r['RoleName'],
+                        PolicyArn=policy_arn)
+                except client.exceptions.NoSuchEntityException:
+                    pass
+
+
 ######################
 #    IAM Policies    #
 ######################


Test patch:
diff --git a/tests/data/placebo/test_iam_set_policy_attached/iam.AttachRolePolicy_1.json b/tests/data/placebo/test_iam_set_policy_attached/iam.AttachRolePolicy_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_set_policy_attached/iam.AttachRolePolicy_1.json
@@ -0,0 +1,16 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0,
+            "HTTPStatusCode": 200,
+            "RequestId": "88991a42-b414-11e7-bd64-7d523ade9cf4",
+            "HTTPHeaders": {
+                "x-amzn-requestid": "88991a42-b414-11e7-bd64-7d523ade9cf4",
+                "date": "Wed, 18 Oct 2017 14:56:33 GMT",
+                "content-length": "200",
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_set_policy_attached/iam.ListAttachedRolePolicies_1.json b/tests/data/placebo/test_iam_set_policy_attached/iam.ListAttachedRolePolicies_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_set_policy_attached/iam.ListAttachedRolePolicies_1.json
@@ -0,0 +1,18 @@
+{
+    "status_code": 200,
+    "data": {
+        "AttachedPolicies": [],
+	    "IsTruncated": false,
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "4811612a-872f-11e6-acfe-07a6295c8f0a", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "4811612a-872f-11e6-acfe-07a6295c8f0a", 
+                "date": "Fri, 30 Sep 2016 16:59:42 GMT", 
+                "content-length": "323", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
diff --git a/tests/data/placebo/test_iam_set_policy_attached/iam.ListRoles_1.json b/tests/data/placebo/test_iam_set_policy_attached/iam.ListRoles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_set_policy_attached/iam.ListRoles_1.json
@@ -0,0 +1,36 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "47a7075d-872f-11e6-9a3a-bf7b1425e59f", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "47a7075d-872f-11e6-9a3a-bf7b1425e59f", 
+                "date": "Fri, 30 Sep 2016 16:59:40 GMT", 
+                "content-length": "4194", 
+                "content-type": "text/xml"
+            }
+        }, 
+        "IsTruncated": false, 
+        "Roles": [
+            {
+                "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22%22%2C%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22config.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", 
+                "RoleId": "AROAJUQKESVQ5AAKMZYH4", 
+                "CreateDate": {
+                    "hour": 10, 
+                    "__class__": "datetime", 
+                    "month": 7, 
+                    "second": 12, 
+                    "microsecond": 0, 
+                    "year": 2016, 
+                    "day": 24, 
+                    "minute": 38
+                }, 
+                "RoleName": "test-role-us-east-1",
+                "Path": "/human-role/",
+                "Arn": "arn:aws:iam::644160558196:role/human-role/test-role-us-east-1"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_set_policy_detached/iam.DetachRolePolicy_1.json b/tests/data/placebo/test_iam_set_policy_detached/iam.DetachRolePolicy_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_set_policy_detached/iam.DetachRolePolicy_1.json
@@ -0,0 +1,16 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0,
+            "HTTPStatusCode": 200,
+            "RequestId": "88991a42-b414-11e7-bd64-7d523ade9cf4",
+            "HTTPHeaders": {
+                "x-amzn-requestid": "88991a42-b414-11e7-bd64-7d523ade9cf4",
+                "date": "Wed, 18 Oct 2017 14:56:33 GMT",
+                "content-length": "200",
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_set_policy_detached/iam.ListAttachedRolePolicies_1.json b/tests/data/placebo/test_iam_set_policy_detached/iam.ListAttachedRolePolicies_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_set_policy_detached/iam.ListAttachedRolePolicies_1.json
@@ -0,0 +1,23 @@
+{
+    "status_code": 200, 
+    "data": {
+        "AttachedPolicies": [
+            {
+                "PolicyName": "my-iam-policy",
+                "PolicyArn": "arn:aws:iam::123456789012:policy/my-iam-policy"
+            }
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "4811612a-872f-11e6-acfe-07a6295c8f0a", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "4811612a-872f-11e6-acfe-07a6295c8f0a", 
+                "date": "Fri, 30 Sep 2016 16:59:42 GMT", 
+                "content-length": "323", 
+                "content-type": "text/xml"
+            }
+        }
+    }
+}
diff --git a/tests/data/placebo/test_iam_set_policy_detached/iam.ListRoles_1.json b/tests/data/placebo/test_iam_set_policy_detached/iam.ListRoles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_set_policy_detached/iam.ListRoles_1.json
@@ -0,0 +1,36 @@
+{
+    "status_code": 200, 
+    "data": {
+        "ResponseMetadata": {
+            "RetryAttempts": 0, 
+            "HTTPStatusCode": 200, 
+            "RequestId": "47a7075d-872f-11e6-9a3a-bf7b1425e59f", 
+            "HTTPHeaders": {
+                "x-amzn-requestid": "47a7075d-872f-11e6-9a3a-bf7b1425e59f", 
+                "date": "Fri, 30 Sep 2016 16:59:40 GMT", 
+                "content-length": "4194", 
+                "content-type": "text/xml"
+            }
+        }, 
+        "IsTruncated": false, 
+        "Roles": [
+            {
+                "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Sid%22%3A%22%22%2C%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22config.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", 
+                "RoleId": "AROAJUQKESVQ5AAKMZYH4", 
+                "CreateDate": {
+                    "hour": 10, 
+                    "__class__": "datetime", 
+                    "month": 7, 
+                    "second": 12, 
+                    "microsecond": 0, 
+                    "year": 2016, 
+                    "day": 24, 
+                    "minute": 38
+                }, 
+                "RoleName": "test-role-us-east-1",
+                "Path": "/human-role/",
+                "Arn": "arn:aws:iam::644160558196:role/human-role/test-role-us-east-1"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/test_iam.py b/tests/test_iam.py
--- a/tests/test_iam.py
+++ b/tests/test_iam.py
@@ -1184,3 +1184,63 @@ def test_s3_principal_org_id(self):
         for p, expected in zip(policies, [False, True]):
             violations = checker.check(p)
             self.assertEqual(bool(violations), expected)
+
+
+class SetRolePolicyAction(BaseTest):
+    def test_set_policy_attached(self):
+        factory = self.replay_flight_data("test_iam_set_policy_attached")
+
+        p = self.load_policy(
+            {
+                "name": "iam-attach-role-policy",
+                "resource": "iam-role",
+                "filters": [
+                    {
+                        "type": "no-specific-managed-policy",
+                        "value": "my-iam-policy",
+                    }
+                ],
+                "actions": [
+                    {
+                        "type": "set-policy",
+                        "state": "attached",
+                        "arn": "arn:aws:iam::123456789012:policy/my-iam-policy",
+                    }
+                ]
+            },
+            session_factory=factory
+        )
+
+        resources = p.run()
+
+        self.assertEqual(len(resources), 1)
+        self.assertIn('test-role-us-east-1', resources[0]['RoleName'])
+
+    def test_set_policy_detached(self):
+        factory = self.replay_flight_data("test_iam_set_policy_detached")
+
+        p = self.load_policy(
+            {
+                "name": "iam-attach-role-policy",
+                "resource": "iam-role",
+                "filters": [
+                    {
+                        "type": "has-specific-managed-policy",
+                        "value": "my-iam-policy",
+                    }
+                ],
+                "actions": [
+                    {
+                        "type": "set-policy",
+                        "state": "detached",
+                        "arn": "arn:aws:iam::123456789012:policy/my-iam-policy",
+                    }
+                ]
+            },
+            session_factory=factory
+        )
+
+        resources = p.run()
+
+        self.assertEqual(len(resources), 1)
+        self.assertIn('test-role-us-east-1', resources[0]['RoleName'])