Repository: cloud-custodian/cloud-custodian

Issue:
Permissions Boundary Policy support for AWS IAM Roles
Problem Statement:
There doesn't appear to a c7n action that can manage permissions boundary policies for IAM roles.  

Proposed Solution:
A solution to this would likely look very similar to the aws.iam-role action called set-policy where instead of making calls like "client.attach_role_policy" and "client.detach_role_policy" it would call "client.put_role_permissions_boundary" and client.delete_role_permissions_boundary". 

Ideally, this would also support IAM users but there doesn't appear to be an existing aws.iam-user action for set-policy (likely due to best practice of not assigning policies directly to users).  The boto actions for that would be:
put_user_permissions_boundary()
delete_user_permissions_boundary()





Discussion:
sounds good, related #5530 (per recent support for boundaries in simulate permissions).

I agree, we want this for both role and user.

Resolving patch:
diff --git a/c7n/resources/iam.py b/c7n/resources/iam.py
--- a/c7n/resources/iam.py
+++ b/c7n/resources/iam.py
@@ -38,7 +38,8 @@
 from c7n.query import QueryResourceManager, DescribeSource, TypeInfo
 from c7n.resolver import ValuesFrom
 from c7n.tags import TagActionFilter, TagDelayedAction, Tag, RemoveTag
-from c7n.utils import local_session, type_schema, chunks, filter_empty, QueryParser
+from c7n.utils import (
+    get_partition, local_session, type_schema, chunks, filter_empty, QueryParser)
 
 from c7n.resources.aws import Arn
 from c7n.resources.securityhub import OtherResourcePostFinding
@@ -148,6 +149,59 @@ def process_resource_set(self, client, roles, tags):
                 continue
 
 
+class SetBoundary(BaseAction):
+    """Set IAM Permission boundary on an IAM Role or User.
+
+    A role or user can only have a single permission boundary set.
+    """
+
+    schema = type_schema(
+        'set-boundary',
+        state={'enum': ['present', 'absent']},
+        policy={'type': 'string'})
+
+    def validate(self):
+        state = self.data.get('state', 'present') == 'present'
+        if state and not self.data.get('policy'):
+            raise PolicyValidationError("set-boundary requires policy arn")
+
+    def process(self, resources):
+        state = self.data.get('state', 'present') == 'present'
+        client = self.manager.session_factory().client('iam')
+        policy = self.data.get('policy')
+        if policy and not policy.startswith('arn'):
+            policy = 'arn:{}:iam::{}:policy/{}'.format(
+                get_partition(self.manager.config.region),
+                self.manager.account_id, policy)
+        for r in resources:
+            method, params = self.get_method(client, state, policy, r)
+            try:
+                self.manager.retry(method, **params)
+            except client.exceptions.NoSuchEntityException:
+                continue
+
+    def get_method(self, client, state, policy, resource):
+        raise NotImplementedError()
+
+
+@Role.action_registry.register('set-boundary')
+class RoleSetBoundary(SetBoundary):
+
+    def get_permissions(self):
+        if self.data.get('state', True):
+            return ('iam:PutRolePermissionsBoundary',)
+        return ('iam:DeleteRolePermissionsBoundary',)
+
+    def get_method(self, client, state, policy, resource):
+        if state:
+            return client.put_role_permissions_boundary, {
+                'RoleName': resource['RoleName'],
+                'PermissionsBoundary': policy}
+        else:
+            return client.delete_role_permissions_boundary, {
+                'RoleName': resource['RoleName']}
+
+
 @resources.register('iam-user')
 class User(QueryResourceManager):
 
@@ -266,6 +320,24 @@ def process(self, resources):
                 continue
 
 
+@User.action_registry.register('set-boundary')
+class UserSetBoundary(SetBoundary):
+
+    def get_permissions(self):
+        if self.data.get('state', True):
+            return ('iam:PutUserPermissionsBoundary',)
+        return ('iam:DeleteUserPermissionsBoundary',)
+
+    def get_method(self, client, state, policy, resource):
+        if state:
+            return client.put_user_permissions_boundary, {
+                'UserName': resource['UserName'],
+                'PermissionsBoundary': policy}
+        else:
+            return client.delete_user_permissions_boundary, {
+                'UserName': resource['UserName']}
+
+
 @resources.register('iam-policy')
 class Policy(QueryResourceManager):
 


Test patch:
diff --git a/tests/data/placebo/test_iam_role_remove_boundary/iam.DeleteRolePermissionsBoundary_1.json b/tests/data/placebo/test_iam_role_remove_boundary/iam.DeleteRolePermissionsBoundary_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_remove_boundary/iam.DeleteRolePermissionsBoundary_1.json
@@ -0,0 +1,6 @@
+{
+  "status_code": 200,
+  "data": {
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_role_remove_boundary/iam.GetRole_1.json b/tests/data/placebo/test_iam_role_remove_boundary/iam.GetRole_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_remove_boundary/iam.GetRole_1.json
@@ -0,0 +1,35 @@
+{
+  "status_code": 200,
+  "data": {
+    "Role": {
+      "Path": "/",
+      "RoleName": "accountmgr-dev",
+      "RoleId": "AROAZL6XWCR2JVSTMAF3J",
+      "Arn": "arn:aws:iam::644160558196:role/accountmgr-dev",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2019,
+        "month": 5,
+        "day": 21,
+        "hour": 22,
+        "minute": 42,
+        "second": 1,
+        "microsecond": 0
+      },
+      "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%22lambda.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D",
+      "MaxSessionDuration": 3600,
+      "PermissionsBoundary": {
+        "PermissionsBoundaryType": "Policy",
+        "PermissionsBoundaryArn": "arn:aws:iam::644160558196:policy/BlackListIamList"
+      },
+      "Tags": [
+        {
+          "Key": "Owner",
+          "Value": "kapil"
+        }
+      ],
+      "RoleLastUsed": {}
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_role_remove_boundary/iam.GetRole_2.json b/tests/data/placebo/test_iam_role_remove_boundary/iam.GetRole_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_remove_boundary/iam.GetRole_2.json
@@ -0,0 +1,31 @@
+{
+  "status_code": 200,
+  "data": {
+    "Role": {
+      "Path": "/",
+      "RoleName": "accountmgr-dev",
+      "RoleId": "AROAZL6XWCR2JVSTMAF3J",
+      "Arn": "arn:aws:iam::644160558196:role/accountmgr-dev",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2019,
+        "month": 5,
+        "day": 21,
+        "hour": 22,
+        "minute": 42,
+        "second": 1,
+        "microsecond": 0
+      },
+      "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%22lambda.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D",
+      "MaxSessionDuration": 3600,
+      "Tags": [
+        {
+          "Key": "Owner",
+          "Value": "kapil"
+        }
+      ],
+      "RoleLastUsed": {}
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_role_remove_boundary/iam.ListRoles_1.json b/tests/data/placebo/test_iam_role_remove_boundary/iam.ListRoles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_remove_boundary/iam.ListRoles_1.json
@@ -0,0 +1,25 @@
+{
+  "status_code": 200,
+  "data": {
+    "Roles": [
+      {
+        "Path": "/",
+        "RoleName": "accountmgr-dev",
+        "RoleId": "AROAZL6XWCR2JVSTMAF3J",
+        "Arn": "arn:aws:iam::644160558196:role/accountmgr-dev",
+        "CreateDate": {
+          "__class__": "datetime",
+          "year": 2019,
+          "month": 5,
+          "day": 21,
+          "hour": 22,
+          "minute": 42,
+          "second": 1,
+          "microsecond": 0
+        }
+      }
+    ],
+    "IsTruncated": false,
+    "ResponseMetadata": {}
+  }
+}
diff --git a/tests/data/placebo/test_iam_role_set_boundary/iam.GetRole_1.json b/tests/data/placebo/test_iam_role_set_boundary/iam.GetRole_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_set_boundary/iam.GetRole_1.json
@@ -0,0 +1,31 @@
+{
+  "status_code": 200,
+  "data": {
+    "Role": {
+      "Path": "/",
+      "RoleName": "accountmgr-dev",
+      "RoleId": "AROAZL6XWCR2JVSTMAF3J",
+      "Arn": "arn:aws:iam::644160558196:role/accountmgr-dev",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2019,
+        "month": 5,
+        "day": 21,
+        "hour": 22,
+        "minute": 42,
+        "second": 1,
+        "microsecond": 0
+      },
+      "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%22lambda.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D",
+      "MaxSessionDuration": 3600,
+      "Tags": [
+        {
+          "Key": "Owner",
+          "Value": "kapil"
+        }
+      ],
+      "RoleLastUsed": {}
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_role_set_boundary/iam.GetRole_2.json b/tests/data/placebo/test_iam_role_set_boundary/iam.GetRole_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_set_boundary/iam.GetRole_2.json
@@ -0,0 +1,35 @@
+{
+  "status_code": 200,
+  "data": {
+    "Role": {
+      "Path": "/",
+      "RoleName": "accountmgr-dev",
+      "RoleId": "AROAZL6XWCR2JVSTMAF3J",
+      "Arn": "arn:aws:iam::644160558196:role/accountmgr-dev",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2019,
+        "month": 5,
+        "day": 21,
+        "hour": 22,
+        "minute": 42,
+        "second": 1,
+        "microsecond": 0
+      },
+      "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%22lambda.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D",
+      "MaxSessionDuration": 3600,
+      "PermissionsBoundary": {
+        "PermissionsBoundaryType": "Policy",
+        "PermissionsBoundaryArn": "arn:aws:iam::644160558196:policy/BlackListIamList"
+      },
+      "Tags": [
+        {
+          "Key": "Owner",
+          "Value": "kapil"
+        }
+      ],
+      "RoleLastUsed": {}
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_role_set_boundary/iam.ListRoles_1.json b/tests/data/placebo/test_iam_role_set_boundary/iam.ListRoles_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_set_boundary/iam.ListRoles_1.json
@@ -0,0 +1,25 @@
+{
+  "status_code": 200,
+  "data": {
+    "Roles": [
+      {
+        "Path": "/",
+        "RoleName": "accountmgr-dev",
+        "RoleId": "AROAZL6XWCR2JVSTMAF3J",
+        "Arn": "arn:aws:iam::644160558196:role/accountmgr-dev",
+        "CreateDate": {
+          "__class__": "datetime",
+          "year": 2019,
+          "month": 5,
+          "day": 21,
+          "hour": 22,
+          "minute": 42,
+          "second": 1,
+          "microsecond": 0
+        }
+      }
+    ],
+    "IsTruncated": false,
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_role_set_boundary/iam.PutRolePermissionsBoundary_1.json b/tests/data/placebo/test_iam_role_set_boundary/iam.PutRolePermissionsBoundary_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_role_set_boundary/iam.PutRolePermissionsBoundary_1.json
@@ -0,0 +1,6 @@
+{
+  "status_code": 200,
+  "data": {
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_remove_boundary/iam.DeleteUserPermissionsBoundary_1.json b/tests/data/placebo/test_iam_user_remove_boundary/iam.DeleteUserPermissionsBoundary_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_remove_boundary/iam.DeleteUserPermissionsBoundary_1.json
@@ -0,0 +1,6 @@
+{
+  "status_code": 200,
+  "data": {
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_remove_boundary/iam.GetUser_1.json b/tests/data/placebo/test_iam_user_remove_boundary/iam.GetUser_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_remove_boundary/iam.GetUser_1.json
@@ -0,0 +1,26 @@
+{
+  "status_code": 200,
+  "data": {
+    "User": {
+      "Path": "/",
+      "UserName": "devbot",
+      "UserId": "AIDAZL6XWCR2OXOEADG5A",
+      "Arn": "arn:aws:iam::644160558196:user/devbot",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2020,
+        "month": 4,
+        "day": 2,
+        "hour": 10,
+        "minute": 36,
+        "second": 22,
+        "microsecond": 0
+      },
+      "PermissionsBoundary": {
+        "PermissionsBoundaryType": "Policy",
+        "PermissionsBoundaryArn": "arn:aws:iam::644160558196:policy/BlackListIamList"
+      }
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_remove_boundary/iam.GetUser_2.json b/tests/data/placebo/test_iam_user_remove_boundary/iam.GetUser_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_remove_boundary/iam.GetUser_2.json
@@ -0,0 +1,22 @@
+{
+  "status_code": 200,
+  "data": {
+    "User": {
+      "Path": "/",
+      "UserName": "devbot",
+      "UserId": "AIDAZL6XWCR2OXOEADG5A",
+      "Arn": "arn:aws:iam::644160558196:user/devbot",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2020,
+        "month": 4,
+        "day": 2,
+        "hour": 10,
+        "minute": 36,
+        "second": 22,
+        "microsecond": 0
+      }
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_remove_boundary/iam.ListUsers_1.json b/tests/data/placebo/test_iam_user_remove_boundary/iam.ListUsers_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_remove_boundary/iam.ListUsers_1.json
@@ -0,0 +1,25 @@
+{
+  "status_code": 200,
+  "data": {
+    "Users": [
+      {
+        "Path": "/",
+        "UserName": "devbot",
+        "UserId": "AIDAZL6XWCR2OXOEADG5A",
+        "Arn": "arn:aws:iam::644160558196:user/devbot",
+        "CreateDate": {
+          "__class__": "datetime",
+          "year": 2020,
+          "month": 4,
+          "day": 2,
+          "hour": 10,
+          "minute": 36,
+          "second": 22,
+          "microsecond": 0
+        }
+      }
+    ],
+    "IsTruncated": false,
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_set_boundary/iam.GetUser_1.json b/tests/data/placebo/test_iam_user_set_boundary/iam.GetUser_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_set_boundary/iam.GetUser_1.json
@@ -0,0 +1,22 @@
+{
+  "status_code": 200,
+  "data": {
+    "User": {
+      "Path": "/",
+      "UserName": "devbot",
+      "UserId": "AIDAZL6XWCR2OXOEADG5A",
+      "Arn": "arn:aws:iam::644160558196:user/devbot",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2020,
+        "month": 4,
+        "day": 2,
+        "hour": 10,
+        "minute": 36,
+        "second": 22,
+        "microsecond": 0
+      }
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_set_boundary/iam.GetUser_2.json b/tests/data/placebo/test_iam_user_set_boundary/iam.GetUser_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_set_boundary/iam.GetUser_2.json
@@ -0,0 +1,26 @@
+{
+  "status_code": 200,
+  "data": {
+    "User": {
+      "Path": "/",
+      "UserName": "devbot",
+      "UserId": "AIDAZL6XWCR2OXOEADG5A",
+      "Arn": "arn:aws:iam::644160558196:user/devbot",
+      "CreateDate": {
+        "__class__": "datetime",
+        "year": 2020,
+        "month": 4,
+        "day": 2,
+        "hour": 10,
+        "minute": 36,
+        "second": 22,
+        "microsecond": 0
+      },
+      "PermissionsBoundary": {
+        "PermissionsBoundaryType": "Policy",
+        "PermissionsBoundaryArn": "arn:aws:iam::644160558196:policy/BlackListIamList"
+      }
+    },
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_set_boundary/iam.ListUsers_1.json b/tests/data/placebo/test_iam_user_set_boundary/iam.ListUsers_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_set_boundary/iam.ListUsers_1.json
@@ -0,0 +1,25 @@
+{
+  "status_code": 200,
+  "data": {
+    "Users": [
+      {
+        "Path": "/",
+        "UserName": "devbot",
+        "UserId": "AIDAZL6XWCR2OXOEADG5A",
+        "Arn": "arn:aws:iam::644160558196:user/devbot",
+        "CreateDate": {
+          "__class__": "datetime",
+          "year": 2020,
+          "month": 4,
+          "day": 2,
+          "hour": 10,
+          "minute": 36,
+          "second": 22,
+          "microsecond": 0
+        }
+      }
+    ],
+    "IsTruncated": false,
+    "ResponseMetadata": {}
+  }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_iam_user_set_boundary/iam.PutUserPermissionsBoundary_1.json b/tests/data/placebo/test_iam_user_set_boundary/iam.PutUserPermissionsBoundary_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_iam_user_set_boundary/iam.PutUserPermissionsBoundary_1.json
@@ -0,0 +1,6 @@
+{
+  "status_code": 200,
+  "data": {
+    "ResponseMetadata": {}
+  }
+}
\ 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
@@ -25,6 +25,7 @@
 from dateutil import parser
 
 from c7n.exceptions import PolicyValidationError
+from c7n.executor import MainThreadExecutor
 from c7n.filters.iamaccess import CrossAccountAccessFilter, PolicyChecker
 from c7n.mu import LambdaManager, LambdaFunction, PythonPackageArchive
 from botocore.exceptions import ClientError
@@ -50,8 +51,6 @@
     PolicyQueryParser
 )
 
-from c7n.executor import MainThreadExecutor
-
 
 class UserCredentialReportTest(BaseTest):
 
@@ -376,7 +375,7 @@ def test_iam_mfa_filter(self):
         self.assertEqual(len(resources), 2)
 
 
-class IamRoleFilterUsage(BaseTest):
+class IamRoleTest(BaseTest):
 
     def test_iam_role_inuse(self):
         session_factory = self.replay_flight_data("test_iam_role_inuse")
@@ -417,9 +416,6 @@ def test_iam_role_get_resources(self):
         self.assertEqual(len(resources), 1)
         self.assertEqual(resources[0]['RoleId'], "AROAIGK7B2VUDZL4I73HK")
 
-
-class IamRoleTag(BaseTest):
-
     def test_iam_role_actions(self):
         factory = self.replay_flight_data('test_iam_role_tags')
         p = self.load_policy({
@@ -452,9 +448,106 @@ def test_iam_role_actions(self):
             {'Application'},
             {t['Key'] for t in role['Tags']})
 
+    def test_iam_role_set_boundary(self):
+        factory = self.replay_flight_data('test_iam_role_set_boundary')
+        p = self.load_policy({
+            'name': 'boundary',
+            'resource': 'iam-role',
+            'filters': [
+                {'RoleName': 'accountmgr-dev'},
+                {'PermissionsBoundary': 'absent'}
+            ],
+            'actions': [{
+                'type': 'set-boundary',
+                'policy': 'arn:aws:iam::644160558196:policy/BlackListIamList'
+            }]},
+            session_factory=factory)
+        resources = p.run()
+        assert len(resources) == 1
+        assert resources[0]['RoleName'] == 'accountmgr-dev'
+        if self.recording:
+            time.sleep(2)
+        client = factory().client('iam')
+        assert client.get_role(RoleName='accountmgr-dev')[
+            'Role'].get('PermissionsBoundary', {}) == {
+                'PermissionsBoundaryType': 'Policy',
+                'PermissionsBoundaryArn': 'arn:aws:iam::644160558196:policy/BlackListIamList',
+        }
+
+    def test_iam_role_remove_boundary(self):
+        factory = self.replay_flight_data('test_iam_role_remove_boundary')
+        p = self.load_policy({
+            'name': 'boundary',
+            'resource': 'iam-role',
+            'filters': [
+                {'RoleName': 'accountmgr-dev'},
+                {'PermissionsBoundary': 'present'}
+            ],
+            'actions': [{
+                'type': 'set-boundary',
+                'state': 'absent',
+            }]},
+            session_factory=factory)
+        p.resource_manager.execution_factory = MainThreadExecutor
+        resources = p.run()
+        assert len(resources) == 1
+        assert resources[0]['RoleName'] == 'accountmgr-dev'
+        if self.recording:
+            time.sleep(5)
+        client = factory().client('iam')
+        assert client.get_role(RoleName='accountmgr-dev')[
+            'Role'].get('PermissionsBoundary', {}) == {}
+
 
 class IamUserTest(BaseTest):
 
+    def test_iam_user_set_boundary(self):
+        factory = self.replay_flight_data('test_iam_user_set_boundary')
+        p = self.load_policy({
+            'name': 'boundary',
+            'resource': 'iam-user',
+            'filters': [
+                {'UserName': 'devbot'},
+                {'PermissionsBoundary': 'absent'}],
+            'actions': [{
+                'type': 'set-boundary',
+                'policy': 'arn:aws:iam::644160558196:policy/BlackListIamList',
+            }]},
+            session_factory=factory)
+
+        resources = p.run()
+        assert len(resources) == 1
+        assert resources[0]['UserName'] == 'devbot'
+
+        if self.recording:
+            time.sleep(1)
+        client = factory().client('iam')
+        assert client.get_user(UserName='devbot')['User'].get('PermissionsBoundary', {}) == {
+            'PermissionsBoundaryType': 'Policy',
+            'PermissionsBoundaryArn': 'arn:aws:iam::644160558196:policy/BlackListIamList'
+        }
+
+    def test_iam_user_boundary_remove(self):
+        factory = self.replay_flight_data('test_iam_user_remove_boundary')
+        p = self.load_policy({
+            'name': 'boundary',
+            'resource': 'iam-user',
+            'filters': [
+                {'UserName': 'devbot'},
+                {'PermissionsBoundary': 'present'}],
+            'actions': [{
+                'type': 'set-boundary', 'state': 'absent'}]},
+            session_factory=factory)
+
+        resources = p.run()
+        assert len(resources) == 1
+        assert resources[0]['UserName'] == 'devbot'
+
+        if self.recording:
+            time.sleep(1)
+        client = factory().client('iam')
+        assert client.get_user(UserName='devbot')['User'].get('PermissionsBoundary', {}) == {}
+
     def test_iam_user_usage_no_such_entity(self):
         p = self.load_policy({
             'name': 'usage-check',
diff --git a/tests/zpill.py b/tests/zpill.py
--- a/tests/zpill.py
+++ b/tests/zpill.py
@@ -272,9 +272,9 @@ def save_response(self, service, operation, response_data,
         if 'ResponseMetadata' in response_data:
             response_data['ResponseMetadata'] = {}
 
-        response_data = json.dumps(response_data, default=self.datetime_converter)
+        response_data = json.dumps(response_data, default=serialize)
         response_data = re.sub("\d{12}", ACCOUNT_ID, response_data)  # noqa
-        response_data = json.loads(response_data)
+        response_data = json.loads(response_data, object_hook=deserialize)
 
         super(RedPill, self).save_response(service, operation, response_data,
                     http_response)