Repository: cloud-custodian/cloud-custodian

Issue:
Ability to remove cross-account access to EBS snapshots for matched account IDs
**Is your feature request related to a problem? Please describe.**
I have cases where we share EBS snapshots with various account IDs. I can use filters to find EBS snapshots that are shared with unauthorised accounts, which would be any account _not_ contained in the `cross-account whitelist`. However, the only remediation available to me (from what I can see) is a `delete` action which deletes the snapshot.

**Describe the solution you'd like**
I would like to be able to remove the share to the unauthorised account, while still keeping the share to the accounts listed in the whitelist, without deleting the EBS snapshot.

**Additional context**
This is somewhat similar to the use case I described in #6008 (which was for AMIs).


Discussion:

Patch:
diff --git a/c7n/resources/ebs.py b/c7n/resources/ebs.py
index 3eeb5ab41..e1699c4e9 100644
--- a/c7n/resources/ebs.py
+++ b/c7n/resources/ebs.py
@@ -513,6 +513,95 @@ class CopySnapshot(BaseAction):
                 "Cross region copy complete %s", ",".join(copy_ids))
 
 
+@Snapshot.action_registry.register('set-permissions')
+class SetPermissions(BaseAction):
+    """Action to set permissions for creating volumes from a snapshot
+
+    Use the 'add' and 'remove' parameters to control which accounts to
+    add or remove respectively.  The default is to remove any create
+    volume permissions granted to other AWS accounts.
+
+    Combining this action with the 'cross-account' filter allows you
+    greater control over which accounts will be removed, e.g. using a
+    whitelist:
+
+    :example:
+
+    .. code-block:: yaml
+
+            policies:
+              - name: ebs-dont-share-cross-account
+                resource: ebs-snapshot
+                filters:
+                  - type: cross-account
+                    whitelist:
+                    - '112233445566'
+                actions:
+                  - type: set-permissions
+                    remove: matched
+    """
+    schema = type_schema(
+        'set-permissions',
+        remove={
+            'oneOf': [
+                {'enum': ['matched']},
+                {'type': 'array', 'items': {
+                    'type': 'string', 'minLength': 12, 'maxLength': 12}},
+            ]},
+        add={
+            'type': 'array', 'items': {
+                'type': 'string', 'minLength': 12, 'maxLength': 12}},
+    )
+
+    permissions = ('ec2:ModifySnapshotAttribute',)
+
+    def validate(self):
+        if self.data.get('remove') == 'matched':
+            found = False
+            for f in self.manager.iter_filters():
+                if isinstance(f, SnapshotCrossAccountAccess):
+                    found = True
+                    break
+            if not found:
+                raise PolicyValidationError(
+                    "policy:%s filter:%s with matched requires cross-account filter" % (
+                        self.manager.ctx.policy.name, self.type))
+
+    def process(self, snapshots):
+        client = local_session(self.manager.session_factory).client('ec2')
+        for i in snapshots:
+            self.process_image(client, i)
+
+    def process_image(self, client, snapshot):
+        add_accounts = self.data.get('add', [])
+        remove_accounts = self.data.get('remove', [])
+        if not add_accounts and not remove_accounts:
+            return client.reset_snapshot_attribute(
+                SnapshotId=snapshot['SnapshotId'], Attribute="createVolumePermission")
+        if remove_accounts == 'matched':
+            remove_accounts = snapshot.get(
+                'c7n:' + SnapshotCrossAccountAccess.annotation_key)
+
+        remove = []
+        remove.extend([{'UserId': a} for a in remove_accounts if a != 'all'])
+        if 'all' in remove_accounts:
+            remove.append({'Group': 'all'})
+            remove_accounts.remove('all')
+
+        add = [{'UserId': a} for a in add_accounts]
+
+        if remove:
+            client.modify_snapshot_attribute(
+                SnapshotId=snapshot['SnapshotId'],
+                CreateVolumePermission={'Remove': remove},
+                OperationType='remove')
+        if add:
+            client.modify_snapshot_attribute(
+                SnapshotId=snapshot['SnapshotId'],
+                CreateVolumePermission={'Add': add},
+                OperationType='add')
+
+
 @resources.register('ebs')
 class EBS(QueryResourceManager):
 


Test patch:
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshotAttribute_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshotAttribute_1.json
new file mode 100644
index 000000000..26e543c67
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshotAttribute_1.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "CreateVolumePermissions": [
+            {
+                "UserId": "665544332211"
+            }
+        ],
+        "SnapshotId": "snap-0ac64f0a1f16af706",
+        "ResponseMetadata": {}
+    }
+}
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshotAttribute_2.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshotAttribute_2.json
new file mode 100644
index 000000000..e1e85cb3e
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshotAttribute_2.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "CreateVolumePermissions": [
+            {
+                "UserId": "112233445566"
+            }
+        ],
+        "SnapshotId": "snap-0ac64f0a1f16af706",
+        "ResponseMetadata": {}
+    }
+}
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshots_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshots_1.json
new file mode 100644
index 000000000..f1323ea45
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.DescribeSnapshots_1.json
@@ -0,0 +1,28 @@
+{
+    "status_code": 200,
+    "data": {
+        "Snapshots": [
+            {
+                "Description": "",
+                "Encrypted": false,
+                "OwnerId": "644160558196",
+                "Progress": "100%",
+                "SnapshotId": "snap-0ac64f0a1f16af706",
+                "StartTime": {
+                    "__class__": "datetime",
+                    "year": 2020,
+                    "month": 10,
+                    "day": 14,
+                    "hour": 21,
+                    "minute": 8,
+                    "second": 19,
+                    "microsecond": 177000
+                },
+                "State": "completed",
+                "VolumeId": "vol-0309e0368c8e7c1b0",
+                "VolumeSize": 8
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.ModifySnapshotAttribute_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.ModifySnapshotAttribute_1.json
new file mode 100644
index 000000000..5b2170a07
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.ModifySnapshotAttribute_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.ModifySnapshotAttribute_2.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.ModifySnapshotAttribute_2.json
new file mode 100644
index 000000000..5b2170a07
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_add/ec2.ModifySnapshotAttribute_2.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshotAttribute_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshotAttribute_1.json
new file mode 100644
index 000000000..643cf7603
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshotAttribute_1.json
@@ -0,0 +1,15 @@
+{
+    "status_code": 200,
+    "data": {
+        "CreateVolumePermissions": [
+            {
+                "UserId": "112233445566"
+            },
+            {
+                "UserId": "665544332211"
+            }
+        ],
+        "SnapshotId": "snap-0ac64f0a1f16af706",
+        "ResponseMetadata": {}
+    }
+}
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshotAttribute_2.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshotAttribute_2.json
new file mode 100644
index 000000000..e1e85cb3e
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshotAttribute_2.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "CreateVolumePermissions": [
+            {
+                "UserId": "112233445566"
+            }
+        ],
+        "SnapshotId": "snap-0ac64f0a1f16af706",
+        "ResponseMetadata": {}
+    }
+}
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshots_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshots_1.json
new file mode 100644
index 000000000..f1323ea45
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.DescribeSnapshots_1.json
@@ -0,0 +1,28 @@
+{
+    "status_code": 200,
+    "data": {
+        "Snapshots": [
+            {
+                "Description": "",
+                "Encrypted": false,
+                "OwnerId": "644160558196",
+                "Progress": "100%",
+                "SnapshotId": "snap-0ac64f0a1f16af706",
+                "StartTime": {
+                    "__class__": "datetime",
+                    "year": 2020,
+                    "month": 10,
+                    "day": 14,
+                    "hour": 21,
+                    "minute": 8,
+                    "second": 19,
+                    "microsecond": 177000
+                },
+                "State": "completed",
+                "VolumeId": "vol-0309e0368c8e7c1b0",
+                "VolumeSize": 8
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.ModifySnapshotAttribute_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.ModifySnapshotAttribute_1.json
new file mode 100644
index 000000000..5b2170a07
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_matched/ec2.ModifySnapshotAttribute_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshotAttribute_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshotAttribute_1.json
new file mode 100644
index 000000000..643cf7603
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshotAttribute_1.json
@@ -0,0 +1,15 @@
+{
+    "status_code": 200,
+    "data": {
+        "CreateVolumePermissions": [
+            {
+                "UserId": "112233445566"
+            },
+            {
+                "UserId": "665544332211"
+            }
+        ],
+        "SnapshotId": "snap-0ac64f0a1f16af706",
+        "ResponseMetadata": {}
+    }
+}
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshotAttribute_2.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshotAttribute_2.json
new file mode 100644
index 000000000..58ec8b600
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshotAttribute_2.json
@@ -0,0 +1,8 @@
+{
+    "status_code": 200,
+    "data": {
+        "CreateVolumePermissions": [],
+        "SnapshotId": "snap-0ac64f0a1f16af706",
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshots_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshots_1.json
new file mode 100644
index 000000000..f1323ea45
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.DescribeSnapshots_1.json
@@ -0,0 +1,28 @@
+{
+    "status_code": 200,
+    "data": {
+        "Snapshots": [
+            {
+                "Description": "",
+                "Encrypted": false,
+                "OwnerId": "644160558196",
+                "Progress": "100%",
+                "SnapshotId": "snap-0ac64f0a1f16af706",
+                "StartTime": {
+                    "__class__": "datetime",
+                    "year": 2020,
+                    "month": 10,
+                    "day": 14,
+                    "hour": 21,
+                    "minute": 8,
+                    "second": 19,
+                    "microsecond": 177000
+                },
+                "State": "completed",
+                "VolumeId": "vol-0309e0368c8e7c1b0",
+                "VolumeSize": 8
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.ResetSnapshotAttribute_1.json b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.ResetSnapshotAttribute_1.json
new file mode 100644
index 000000000..5b2170a07
--- /dev/null
+++ b/tests/data/placebo/test_ebs_snapshot_set_permissions_reset/ec2.ResetSnapshotAttribute_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_ebs.py b/tests/test_ebs.py
index 72d1ce4fe..94ae048aa 100644
--- a/tests/test_ebs.py
+++ b/tests/test_ebs.py
@@ -334,6 +334,104 @@ class SnapshotTrimTest(BaseTest):
         self.assertEqual(len(resources), 1)
 
 
+class SnapshotSetPermissions(BaseTest):
+    # The precondition to these tests in here is that we have an EBS
+    # snapshot with create volume permissions for accounts
+    # 112233445566 and 665544332211
+
+    def test_reset(self):
+        factory = self.replay_flight_data(
+            "test_ebs_snapshot_set_permissions_reset")
+        p = self.load_policy(
+            {
+                "name": "reset-permissions",
+                "resource": "ebs-snapshot",
+                "filters": ["cross-account"],
+                "actions": ["set-permissions"],
+            },
+            session_factory=factory,
+        )
+        p.validate()
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        assert sorted(
+            resources[0]['c7n:CrossAccountViolations']) == sorted(
+                ['112233445566', '665544332211'])
+        client = factory().client('ec2')
+        perms = client.describe_snapshot_attribute(
+            SnapshotId=resources[0]['SnapshotId'],
+            Attribute='createVolumePermission')['CreateVolumePermissions']
+        assert perms == []
+
+    def test_add(self):
+        # For this test, we assume only 665544332211 has permissions,
+        # and we test adding 112233445566 and removing 665544332211
+        factory = self.replay_flight_data(
+            "test_ebs_snapshot_set_permissions_add")
+        p = self.load_policy(
+            {
+                "name": "set-permissions",
+                "resource": "ebs-snapshot",
+                "filters": ["cross-account"],
+                "actions": [
+                    {
+                        "type": "set-permissions",
+                        "add": ["112233445566"],
+                        "remove": ["665544332211"],
+                    },
+                ],
+            },
+            session_factory=factory,
+        )
+        p.validate()
+        resources = p.run()
+
+        self.assertEqual(len(resources), 1)
+        client = factory().client('ec2')
+        perms = client.describe_snapshot_attribute(
+            SnapshotId=resources[0]['SnapshotId'],
+            Attribute='createVolumePermission')['CreateVolumePermissions']
+        assert perms == [
+            {"UserId": "112233445566"},
+        ]
+
+    def test_matched(self):
+        factory = self.replay_flight_data(
+            "test_ebs_snapshot_set_permissions_matched")
+        p = self.load_policy(
+            {
+                "name": "set-permissions",
+                "resource": "ebs-snapshot",
+                "filters": [
+                    {
+                        "type": "cross-account",
+                        "whitelist": ["112233445566"],
+                    },
+                ],
+                "actions": [
+                    {
+                        "type": "set-permissions",
+                        "remove": "matched",
+                    },
+                ],
+            },
+            session_factory=factory,
+        )
+        p.validate()
+        resources = p.run()
+
+        self.assertEqual(len(resources), 1)
+        self.assertEqual(
+            sorted(resources[0]['c7n:CrossAccountViolations']),
+            ['665544332211'])
+
+        client = factory().client('ec2')
+        perms = client.describe_snapshot_attribute(
+            SnapshotId=resources[0]['SnapshotId'],
+            Attribute='createVolumePermission')['CreateVolumePermissions']
+        assert perms == [{"UserId": "112233445566"}]
+
+
 class AttachedInstanceTest(BaseTest):
 
     def test_ebs_instance_filter(self):