Repository: cloud-custodian/cloud-custodian

Issue:
add-group action on iam-user resource
Enable the use case of Incident Response is to add the iam-user to a group, where that group has a restrictive set of explicit deny policies to contain what the iam-user can do


Discussion:
is it also removing them from extant groups?

the action here would be set-groups (add/remove options).

Resolving patch:
diff --git a/c7n/resources/iam.py b/c7n/resources/iam.py
--- a/c7n/resources/iam.py
+++ b/c7n/resources/iam.py
@@ -190,6 +190,55 @@ def process_resource_set(self, client, users, tags):
 User.filter_registry.register('marked-for-op', TagActionFilter)
 
 
+@User.action_registry.register('set-groups')
+class SetGroups(BaseAction):
+    """Set a specific IAM user as added/removed from a group
+
+    :example:
+
+      .. code-block:: yaml
+
+        - name: iam-user-add-remove
+          resource: iam-user
+          filters:
+            - type: value
+              key: UserName
+              value: Bob
+          actions:
+            - type: set-groups
+              state: remove
+              group: Admin
+
+    """
+    schema = type_schema(
+        'set-groups',
+        state={'enum': ['add', 'remove']},
+        group={'type': 'string'},
+        required=['state', 'group']
+    )
+
+    permissions = ('iam:AddUserToGroup', 'iam:RemoveUserFromGroup',)
+
+    def validate(self):
+        if self.data.get('group') == '':
+            raise PolicyValidationError('group cannot be empty on %s'
+                % (self.manager.data))
+
+    def process(self, resources):
+        group_name = self.data['group']
+        state = self.data['state']
+        client = local_session(self.manager.session_factory).client('iam')
+        op_map = {
+            'add': client.add_user_to_group,
+            'remove': client.remove_user_from_group
+        }
+        for r in resources:
+            try:
+                op_map[state](GroupName=group_name, UserName=r['UserName'])
+            except client.exceptions.NoSuchEntityException:
+                continue
+
+
 @resources.register('iam-policy')
 class Policy(QueryResourceManager):
 


Test patch:
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.AddUserToGroup_1.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.AddUserToGroup_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.AddUserToGroup_1.json
@@ -0,0 +1,16 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RequestId": "ce6e45dc-d013-11e9-b60d-ff7ac01202ff",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ce6e45dc-d013-11e9-b60d-ff7ac01202ff",
+                "content-type": "text/xml",
+                "content-length": "208",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.GetUser_1.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.GetUser_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.GetUser_1.json
@@ -0,0 +1,32 @@
+{
+    "status_code": 200,
+    "data": {
+        "User": {
+            "Path": "/",
+            "UserName": "Bob",
+            "UserId": "AIDA4WFP4XXC3C4MN2RNZ",
+            "Arn": "arn:aws:iam::123456789000:user/Bob",
+            "CreateDate": {
+                "__class__": "datetime",
+                "year": 2019,
+                "month": 9,
+                "day": 5,
+                "hour": 19,
+                "minute": 29,
+                "second": 2,
+                "microsecond": 0
+            }
+        },
+        "ResponseMetadata": {
+            "RequestId": "ce42f03b-d013-11e9-aaa5-3d97689ee397",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ce42f03b-d013-11e9-aaa5-3d97689ee397",
+                "content-type": "text/xml",
+                "content-length": "455",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.GetUser_2.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.GetUser_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.GetUser_2.json
@@ -0,0 +1,32 @@
+{
+    "status_code": 200,
+    "data": {
+        "User": {
+            "Path": "/",
+            "UserName": "Bob",
+            "UserId": "AIDA4WFP4XXC3C4MN2RNZ",
+            "Arn": "arn:aws:iam::123456789000:user/Bob",
+            "CreateDate": {
+                "__class__": "datetime",
+                "year": 2019,
+                "month": 9,
+                "day": 5,
+                "hour": 19,
+                "minute": 29,
+                "second": 2,
+                "microsecond": 0
+            }
+        },
+        "ResponseMetadata": {
+            "RequestId": "cead98ad-d013-11e9-b58c-6dee0d78c915",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "cead98ad-d013-11e9-b58c-6dee0d78c915",
+                "content-type": "text/xml",
+                "content-length": "455",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_1.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_1.json
@@ -0,0 +1,18 @@
+{
+    "status_code": 200,
+    "data": {
+        "Groups": [],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "ce06d165-d013-11e9-aaa5-3d97689ee397",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ce06d165-d013-11e9-aaa5-3d97689ee397",
+                "content-type": "text/xml",
+                "content-length": "322",
+                "date": "Thu, 05 Sep 2019 19:31:48 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_2.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_2.json
@@ -0,0 +1,35 @@
+{
+    "status_code": 200,
+    "data": {
+        "Groups": [
+            {
+                "Path": "/",
+                "GroupName": "AdminGroup",
+                "GroupId": "AGPAJHYEJ6DMZRAVTSRQA",
+                "Arn": "arn:aws:iam::123456789000:group/AdminGroup",
+                "CreateDate": {
+                    "__class__": "datetime",
+                    "year": 2016,
+                    "month": 6,
+                    "day": 7,
+                    "hour": 5,
+                    "minute": 54,
+                    "second": 38,
+                    "microsecond": 0
+                }
+            }
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "ce75e72b-d013-11e9-aaa5-3d97689ee397",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ce75e72b-d013-11e9-aaa5-3d97689ee397",
+                "content-type": "text/xml",
+                "content-length": "596",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_3.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_3.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListGroupsForUser_3.json
@@ -0,0 +1,18 @@
+{
+    "status_code": 200,
+    "data": {
+        "Groups": [],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "ced56c4d-d013-11e9-aaa5-3d97689ee397",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ced56c4d-d013-11e9-aaa5-3d97689ee397",
+                "content-type": "text/xml",
+                "content-length": "322",
+                "date": "Thu, 05 Sep 2019 19:31:50 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListUsers_1.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListUsers_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListUsers_1.json
@@ -0,0 +1,35 @@
+{
+    "status_code": 200,
+    "data": {
+        "Users": [
+            {
+                "Path": "/",
+                "UserName": "Bob",
+                "UserId": "AIDA4WFP4XXC3C4MN2RNZ",
+                "Arn": "arn:aws:iam::123456789000:user/Bob",
+                "CreateDate": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 9,
+                    "day": 5,
+                    "hour": 19,
+                    "minute": 29,
+                    "second": 2,
+                    "microsecond": 0
+                }
+            }
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "ce21d30f-d013-11e9-aaa5-3d97689ee397",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ce21d30f-d013-11e9-aaa5-3d97689ee397",
+                "content-type": "text/xml",
+                "content-length": "795",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListUsers_2.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListUsers_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.ListUsers_2.json
@@ -0,0 +1,35 @@
+{
+    "status_code": 200,
+    "data": {
+        "Users": [
+            {
+                "Path": "/",
+                "UserName": "Bob",
+                "UserId": "AIDA4WFP4XXC3C4MN2RNZ",
+                "Arn": "arn:aws:iam::123456789000:user/Bob",
+                "CreateDate": {
+                    "__class__": "datetime",
+                    "year": 2019,
+                    "month": 9,
+                    "day": 5,
+                    "hour": 19,
+                    "minute": 29,
+                    "second": 2,
+                    "microsecond": 0
+                }
+            }
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {
+            "RequestId": "ce93a7e0-d013-11e9-b60d-ff7ac01202ff",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "ce93a7e0-d013-11e9-b60d-ff7ac01202ff",
+                "content-type": "text/xml",
+                "content-length": "795",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
diff --git a/tests/data/placebo/test_iam_user_add_remove_groups/iam.RemoveUserFromGroup_1.json b/tests/data/placebo/test_iam_user_add_remove_groups/iam.RemoveUserFromGroup_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_add_remove_groups/iam.RemoveUserFromGroup_1.json
@@ -0,0 +1,16 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {
+            "RequestId": "cecd2e3c-d013-11e9-ae4f-bb716bff761d",
+            "HTTPStatusCode": 200,
+            "HTTPHeaders": {
+                "x-amzn-requestid": "cecd2e3c-d013-11e9-ae4f-bb716bff761d",
+                "content-type": "text/xml",
+                "content-length": "218",
+                "date": "Thu, 05 Sep 2019 19:31:49 GMT"
+            },
+            "RetryAttempts": 0
+        }
+    }
+}
\ 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
@@ -251,6 +251,37 @@ def test_iam_user_actions(self):
             {'Env': 'Dev',
              'maid_status': 'Resource does not meet policy: delete@2019/01/25'})
 
+    def test_iam_user_add_remove_groups(self):
+        factory = self.replay_flight_data('test_iam_user_add_remove_groups')
+        client = factory().client('iam')
+        response = client.list_groups_for_user(UserName='Bob')
+        self.assertEqual(len(response.get('Groups')), 0)
+        p = self.load_policy({
+            'name': 'add-remove-user',
+            'resource': 'iam-user',
+            'filters': [{'type': 'value', 'key': 'UserName', 'value': 'Bob'}],
+            'actions': [
+                {'type': 'set-groups', 'state': 'add', 'group': 'AdminGroup'}
+            ]},
+            session_factory=factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        response = client.list_groups_for_user(UserName='Bob')
+        self.assertEqual(len(response.get('Groups')), 1)
+        self.assertEqual(response.get('Groups')[0]['GroupName'], 'AdminGroup')
+        p = self.load_policy({
+            'name': 'add-remove-user',
+            'resource': 'iam-user',
+            'filters': [{'type': 'value', 'key': 'UserName', 'value': 'Bob'}],
+            'actions': [
+                {'type': 'set-groups', 'state': 'remove', 'group': 'AdminGroup'}
+            ]},
+            session_factory=factory)
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        response = client.list_groups_for_user(UserName='Bob')
+        self.assertEqual(len(response.get('Groups')), 0)
+
 
 class IAMMFAFilter(BaseTest):