Repository: cloud-custodian/cloud-custodian

Issue:
AWS Account - Add organization-member filter
### Describe the feature

I need to be able to check if an AWS account is part of an organization for a compliance check. I would like a filter added to the aws.account resource to do this.  The API to check if an account is part of an organization is ```response = client.describe_organization()``` and if the account is part of an organization it returns the org info. If the account is not part of an organization it will return: ```An error occurred (AWSOrganizationsNotInUseException) when calling the DescribeOrganization operation: Your account is not a member of an organization.```

### Extra information or context

The policy I want to write will look something like this:

```
- name: aws-account-organization-member
  resource: aws.account
  filters:
     - type: organization-member
       value: False
```

Discussion:

Patch:
diff --git a/c7n/resources/account.py b/c7n/resources/account.py
index 1a422b5c0..6b6cdbb27 100644
--- a/c7n/resources/account.py
+++ b/c7n/resources/account.py
@@ -100,6 +100,71 @@ class AccountCredentialReport(CredentialReport):
         return results
 
 
+@filters.register('organization')
+class AccountOrganization(ValueFilter):
+    """Check organization enrollment and configuration
+
+    :example:
+
+    determine if an account is not in an organization
+
+    .. code-block:: yaml
+
+      policies:
+        - name: no-org
+          resource: account
+          filters:
+            - type: organization
+              key: Id
+              value: absent
+
+
+    :example:
+
+    determine if an account is setup for organization policies
+
+    .. code-block:: yaml
+
+       policies:
+         - name: org-policies-not-enabled
+           resource: account
+           filters:
+             - type: organization
+               key: FeatureSet
+               value: ALL
+               op: not-equal
+    """
+    schema = type_schema('organization', rinherit=ValueFilter.schema)
+    schema_alias = False
+
+    annotation_key = 'c7n:org'
+    annotate = False
+
+    permissions = ('organizations:DescribeOrganization',)
+
+    def get_org_info(self, account):
+        client = local_session(
+            self.manager.session_factory).client('organizations')
+        try:
+            org_info = client.describe_organization().get('Organization')
+        except client.exceptions.AWSOrganizationsNotInUseException:
+            org_info = {}
+        except ClientError as e:
+            self.log.warning('organization filter error accessing org info %s', e)
+            org_info = None
+        account[self.annotation_key] = org_info
+
+    def process(self, resources, event=None):
+        if self.annotation_key not in resources[0]:
+            self.get_org_info(resources[0])
+        # if we can't access org info, we've already logged, and return
+        if resources[0][self.annotation_key] is None:
+            return []
+        if super().process([resources[0][self.annotation_key]]):
+            return resources
+        return []
+
+
 @filters.register('check-macie')
 class MacieEnabled(ValueFilter):
     """Check status of macie v2 in the account.
diff --git a/c7n/resources/connect.py b/c7n/resources/connect.py
index b2fb5ede0..af920d0c2 100644
--- a/c7n/resources/connect.py
+++ b/c7n/resources/connect.py
@@ -52,6 +52,7 @@ class ConnectInstanceAttributeFilter(ValueFilter):
             if self.annotation_key not in r:
                 instance_attribute = client.describe_instance_attribute(InstanceId=r['Id'],
                                 AttributeType=str.upper(self.data.get('attribute_type')))
+                instance_attribute.pop('ResponseMetadata', None)
                 r[self.annotation_key] = instance_attribute
 
             if self.match(r[self.annotation_key]):
diff --git a/tools/c7n_left/c7n_left/filters.py b/tools/c7n_left/c7n_left/filters.py
index 2e80586b7..7dee0e881 100644
--- a/tools/c7n_left/c7n_left/filters.py
+++ b/tools/c7n_left/c7n_left/filters.py
@@ -24,7 +24,7 @@ class Traverse(Filter):
           filters:
             - not:
                - type: traverse
-                 resource: aws_s3_bucket_server_side_encryption_configuration
+                 resources: aws_s3_bucket_server_side_encryption_configuration
                  attrs:
                   - rule.apply_server_side_encryption_by_default.sse_algorithm: aws:kms
 
@@ -40,7 +40,7 @@ class Traverse(Filter):
           filters:
             - network_configuration: present
             - type: traverse
-              resource: [aws_apprunner_vpc_connector, aws_subnet, aws_vpc]
+              resources: [aws_apprunner_vpc_connector, aws_subnet, aws_vpc]
               attrs:
                - type: value
                  key: tag:Env
diff --git a/tools/c7n_left/c7n_left/output.py b/tools/c7n_left/c7n_left/output.py
index c537282e7..80b9d581b 100644
--- a/tools/c7n_left/c7n_left/output.py
+++ b/tools/c7n_left/c7n_left/output.py
@@ -94,7 +94,7 @@ class RichCli(Output):
     def on_execution_ended(self):
         message = "[green]Success[green]"
         if self.matches:
-            message = "[red]%d Failures[/red]" % len(self.matches)
+            message = "[red]%d Failures[/red]" % self.matches
         self.console.print(
             "Evaluation complete %0.2f seconds -> %s"
             % (time.time() - self.started, message)


Test patch:
diff --git a/tests/data/placebo/test_account_org_info/iam.ListAccountAliases_1.json b/tests/data/placebo/test_account_org_info/iam.ListAccountAliases_1.json
new file mode 100644
index 000000000..97b529abe
--- /dev/null
+++ b/tests/data/placebo/test_account_org_info/iam.ListAccountAliases_1.json
@@ -0,0 +1,10 @@
+{
+    "status_code": 200,
+    "data": {
+        "AccountAliases": [
+            "realms-root"
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_org_info/organizations.DescribeOrganization_1.json b/tests/data/placebo/test_account_org_info/organizations.DescribeOrganization_1.json
new file mode 100644
index 000000000..a4d13e1e5
--- /dev/null
+++ b/tests/data/placebo/test_account_org_info/organizations.DescribeOrganization_1.json
@@ -0,0 +1,20 @@
+{
+    "status_code": 200,
+    "data": {
+        "Organization": {
+            "Id": "o-vjtynx2e3h",
+            "Arn": "arn:aws:organizations::644160558196:organization/o-vjtynx4d1h",
+            "FeatureSet": "ALL",
+            "MasterAccountArn": "arn:aws:organizations::644160558196:account/o-vjtynx4d1h/644160558196",
+            "MasterAccountId": "644160558196",
+            "MasterAccountEmail": "kapilt@gmail.com",
+            "AvailablePolicyTypes": [
+                {
+                    "Type": "SERVICE_CONTROL_POLICY",
+                    "Status": "ENABLED"
+                }
+            ]
+        },
+        "ResponseMetadata": {}
+    }
+}
diff --git a/tests/data/placebo/test_account_org_info_denied/iam.ListAccountAliases_1.json b/tests/data/placebo/test_account_org_info_denied/iam.ListAccountAliases_1.json
new file mode 100644
index 000000000..97b529abe
--- /dev/null
+++ b/tests/data/placebo/test_account_org_info_denied/iam.ListAccountAliases_1.json
@@ -0,0 +1,10 @@
+{
+    "status_code": 200,
+    "data": {
+        "AccountAliases": [
+            "realms-root"
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_org_info_denied/organizations.DescribeOrganization_1.json b/tests/data/placebo/test_account_org_info_denied/organizations.DescribeOrganization_1.json
new file mode 100644
index 000000000..1ab801caa
--- /dev/null
+++ b/tests/data/placebo/test_account_org_info_denied/organizations.DescribeOrganization_1.json
@@ -0,0 +1,11 @@
+{
+    "status_code": 403,
+    "data": {
+        "Error": {
+            "Message": "not authorized",
+            "Code": "AccessDeniedException"
+        },
+        "ResponseMetadata": {},
+        "message": "not authorized"
+    }
+}
diff --git a/tests/data/placebo/test_account_org_no_org/iam.ListAccountAliases_1.json b/tests/data/placebo/test_account_org_no_org/iam.ListAccountAliases_1.json
new file mode 100644
index 000000000..97b529abe
--- /dev/null
+++ b/tests/data/placebo/test_account_org_no_org/iam.ListAccountAliases_1.json
@@ -0,0 +1,10 @@
+{
+    "status_code": 200,
+    "data": {
+        "AccountAliases": [
+            "realms-root"
+        ],
+        "IsTruncated": false,
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_account_org_no_org/organizations.DescribeOrganization_1.json b/tests/data/placebo/test_account_org_no_org/organizations.DescribeOrganization_1.json
new file mode 100644
index 000000000..a84394039
--- /dev/null
+++ b/tests/data/placebo/test_account_org_no_org/organizations.DescribeOrganization_1.json
@@ -0,0 +1,14 @@
+{
+    "status_code": 404, 
+    "data": {
+        "ResponseMetadata": {
+            "HTTPStatusCode": 404, 
+            "HostId": "4tbXPrkIvHzDe7UTWOeiJGxuomJ8SVVvn9cSKQHVTVJdJtVeYDAkwOkd7C0K9k4WtVzvKf1R2iU=", 
+            "RequestId": "29DAAD9315A10452"
+        }, 
+        "Error": {
+            "Message": "dunno", 
+            "Code": "AWSOrganizationsNotInUseException"
+        }
+    }
+}
diff --git a/tests/test_account.py b/tests/test_account.py
index 821a6841c..aec2e7464 100644
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -69,6 +69,57 @@ class AccountTests(BaseTest):
         resources = p.run()
         self.assertEqual(len(resources), 1)
 
+    def test_org_no_org(self):
+        factory = self.replay_flight_data(
+            'test_account_org_no_org')
+        p = self.load_policy({
+            'name': 'org-check',
+            'resource': 'aws.account',
+            'filters': [{
+                'type': 'organization',
+                'key': 'Id',
+                'value': 'absent'
+            }]},
+            session_factory=factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+
+    def test_org_denied(self):
+        factory = self.replay_flight_data(
+            'test_account_org_info_denied')
+        p = self.load_policy({
+            'name': 'org-check',
+            'resource': 'aws.account',
+            'filters': [{
+                'type': 'organization',
+                'key': 'Id',
+                'value': 'absent'
+            }]},
+            session_factory=factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 0)
+
+    def test_org_info(self):
+        factory = self.replay_flight_data(
+            'test_account_org_info')
+        p = self.load_policy({
+            'name': 'org-check',
+            'resource': 'aws.account',
+            'filters': [{
+                'type': 'organization',
+                'key': 'Id',
+                'op': 'not-equal',
+                'value': 'o-xyz'
+            }]},
+            session_factory=factory
+        )
+        resources = p.run()
+
+        self.assertEqual(len(resources), 1)
+        self.assertEqual(resources[0]['c7n:org']['FeatureSet'], 'ALL')
+
     def test_missing(self):
         session_factory = self.replay_flight_data(
             'test_account_missing_resource_ec2')