Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AWS - Elasticsearch cross-account filter and remove-statements action #6225

Merged
99 changes: 97 additions & 2 deletions c7n/resources/elasticsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
# Copyright The Cloud Custodian Authors.
# SPDX-License-Identifier: Apache-2.0
import jmespath
import json

from c7n.actions import Action, ModifyVpcSecurityGroupsAction
from c7n.filters import MetricsFilter
from c7n.actions import Action, ModifyVpcSecurityGroupsAction, RemovePolicyBase
from c7n.filters import MetricsFilter, CrossAccountAccessFilter
from c7n.exceptions import PolicyValidationError
from c7n.filters.vpc import SecurityGroupFilter, SubnetFilter, VpcFilter
from c7n.manager import resources
from c7n.query import ConfigSource, DescribeSource, QueryResourceManager, TypeInfo
Expand Down Expand Up @@ -115,6 +117,99 @@ class KmsFilter(KmsRelatedFilter):
RelatedIdsExpression = 'EncryptionAtRestOptions.KmsKeyId'


@ElasticSearchDomain.filter_registry.register('cross-account')
class ElasticSearchCrossAccountAccessFilter(CrossAccountAccessFilter):
"""
Filter to return all elasticsearch domains with cross account access permissions

:example:

.. code-block:: yaml

policies:
- name: check-elasticsearch-cross-account
resource: aws.elasticsearch
filters:
- type: cross-account
"""
policy_attribute = 'c7n:Policy'
permissions = ('es:DescribeElasticsearchDomainConfig',)

def process(self, resources, event=None):
client = local_session(self.manager.session_factory).client('es')
for r in resources:
result = self.manager.retry(
kapilt marked this conversation as resolved.
Show resolved Hide resolved
client.describe_elasticsearch_domain_config,
DomainName=r['DomainName'],
ignore_err_codes=('ResourceNotFoundException',))
r[self.policy_attribute] = json.loads(
kapilt marked this conversation as resolved.
Show resolved Hide resolved
result['DomainConfig']['AccessPolicies']['Options']
)
return super().process(resources)


@ElasticSearchDomain.action_registry.register('remove-statements')
class RemovePolicyStatement(RemovePolicyBase):
"""
Action to remove policy statements from elasticsearch

:example:

.. code-block:: yaml

policies:
- name: elasticsearch-cross-account
resource: aws.elasticsearch
filters:
- type: cross-account
actions:
- type: remove-statements
statement_ids: matched
"""

permissions = ('es:DescribeElasticsearchDomainConfig', 'es:UpdateElasticsearchDomainConfig',)

def validate(self):
for f in self.manager.iter_filters():
if isinstance(f, ElasticSearchCrossAccountAccessFilter):
return self
raise PolicyValidationError(
'`remove-statements` may only be used in '
'conjunction with `cross-account` filter on %s' % (self.manager.data,))

def process(self, resources):
results = []
client = local_session(self.manager.session_factory).client('es')
for r in resources:
try:
results += filter(None, [self.process_resource(client, r)])
kapilt marked this conversation as resolved.
Show resolved Hide resolved
except Exception:
self.log.exception("Error processing es:%s", r['ARN'])
return results

def process_resource(self, client, resource):
p = resource.get('c7n:Policy')
print(p)
kapilt marked this conversation as resolved.
Show resolved Hide resolved

if p is None:
return

statements, found = self.process_policy(
p, resource, CrossAccountAccessFilter.annotation_key)

if not found:
return

client.update_elasticsearch_domain_config(
DomainName=resource['DomainName'],
AccessPolicies=json.dumps(p)
)

return {'Name': resource['ARN'],
'State': 'PolicyRemoved',
'Statements': found}


@ElasticSearchDomain.action_registry.register('post-finding')
class ElasticSearchPostFinding(PostFinding):

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainConfig": {
"AccessPolicies": {
"Options": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"},{\"Sid\":\"CrossAccount\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"es:ESHttpGet\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainStatusList": [
{
"DomainId": "644160558196/test-es",
"DomainName": "test-es",
"ARN": "arn:aws:es:us-east-1:644160558196:domain/test-es",
"Created": true,
"Deleted": false,
"AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"},{\"Sid\":\"CrossAccount\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"es:ESHttpGet\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainNames": [
{
"DomainName": "test-es"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"TagList": []
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainConfig": {
"AccessPolicies": {
"Options": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"},{\"Sid\":\"CrossAccount\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"es:ESHttpGet\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainConfig": {
"AccessPolicies": {
"Options": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainStatusList": [
{
"DomainId": "644160558196/test-es",
"DomainName": "test-es",
"ARN": "arn:aws:es:us-east-1:644160558196:domain/test-es",
"Created": true,
"Deleted": false,
"AccessPolicies": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"},{\"Sid\":\"CrossAccount\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"es:ESHttpGet\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainNames": [
{
"DomainName": "test-es"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"TagList": []
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainConfig": {
"AccessPolicies": {
"Options": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"},{\"Sid\":\"CrossAccount\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"es:ESHttpGet\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"status_code": 200,
"data": {
"ResponseMetadata": {},
"DomainConfig": {
"AccessPolicies": {
"Options": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"SpecificAllow\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::644160558196:root\"},\"Action\":\"es:*\",\"Resource\":\"arn:aws:es:us-east-1:644160558196:domain/test-es/*\"}]}"
}
}
}
}
66 changes: 65 additions & 1 deletion tests/test_elasticsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
# Copyright The Cloud Custodian Authors.
# SPDX-License-Identifier: Apache-2.0
from .common import BaseTest

import json
from c7n.exceptions import PolicyValidationError
from c7n.resources.aws import shape_validate


Expand Down Expand Up @@ -281,6 +282,69 @@ def test_backup_vault_kms_filter(self):
aliases = kms.list_aliases(KeyId=resources[0]['EncryptionAtRestOptions']['KmsKeyId'])
self.assertEqual(aliases['Aliases'][0]['AliasName'], 'alias/aws/es')

def test_elasticsearch_cross_account(self):
session_factory = self.replay_flight_data("test_elasticsearch_cross_account")
p = self.load_policy(
{
"name": "elasticsearch-cross-account",
"resource": "elasticsearch",
"filters": [{"type": "cross-account"}],
},
session_factory=session_factory,
)
resources = p.run()
self.assertEqual(len(resources), 1)
kapilt marked this conversation as resolved.
Show resolved Hide resolved

def test_elasticsearch_remove_matched(self):
session_factory = self.replay_flight_data("test_elasticsearch_remove_matched")
client = session_factory().client("es")
client.update_elasticsearch_domain_config(DomainName='test-es', AccessPolicies=json.dumps(
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SpecificAllow",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::644160558196:root"},
"Action": "es:*",
"Resource": "arn:aws:es:us-east-1:644160558196:domain/test-es/*"
},
{
"Sid": "CrossAccount",
"Effect": "Allow",
"Principal": "*",
"Action": "es:ESHttpGet",
"Resource": "arn:aws:es:us-east-1:644160558196:domain/test-es/*"
},
]
}))
p = self.load_policy(
{
"name": "elasticsearch-rm-matched",
"resource": "elasticsearch",
"filters": [{"type": "cross-account"}],
"actions": [{"type": "remove-statements", "statement_ids": "matched"}],
},
session_factory=session_factory,
)
resources = p.run()
self.assertEqual(len(resources), 1)
data = client.describe_elasticsearch_domain_config(DomainName=resources[0]['DomainName'])
access_policy = json.loads(data['DomainConfig']['AccessPolicies']['Options'])
self.assertEqual(len(access_policy.get('Statement')), 1)
self.assertEqual([s['Sid'] for s in access_policy.get('Statement')], ["SpecificAllow"])

def test_remove_statements_validation_error(self):
self.assertRaises(
PolicyValidationError,
self.load_policy,
{
"name": "elasticsearch-remove-matched",
"resource": "elasticsearch",
"actions": [{"type": "remove-statements", "statement_ids": "matched"}],
}
)


class TestReservedInstances(BaseTest):

Expand Down