Added support for AWS Config Retention Configuration

- Added AWS Config Retention Configuration support
- Also added S3 KMS Key ARN support for the Delivery Channel
- Updated the supported functions page for Config
This commit is contained in:
Mike Grima 2023-04-07 18:06:37 -04:00
parent dc460a3258
commit ae8f2a19c6
No known key found for this signature in database
5 changed files with 259 additions and 4 deletions

View File

@ -62,7 +62,7 @@ config
- [ ] delete_remediation_configuration - [ ] delete_remediation_configuration
- [ ] delete_remediation_exceptions - [ ] delete_remediation_exceptions
- [ ] delete_resource_config - [ ] delete_resource_config
- [ ] delete_retention_configuration - [X] delete_retention_configuration
- [ ] delete_stored_query - [ ] delete_stored_query
- [ ] deliver_config_snapshot - [ ] deliver_config_snapshot
- [ ] describe_aggregate_compliance_by_config_rules - [ ] describe_aggregate_compliance_by_config_rules
@ -91,7 +91,7 @@ config
- [ ] describe_remediation_configurations - [ ] describe_remediation_configurations
- [ ] describe_remediation_exceptions - [ ] describe_remediation_exceptions
- [ ] describe_remediation_execution_status - [ ] describe_remediation_execution_status
- [ ] describe_retention_configurations - [X] describe_retention_configurations
- [ ] get_aggregate_compliance_details_by_config_rule - [ ] get_aggregate_compliance_details_by_config_rule
- [ ] get_aggregate_config_rule_compliance_summary - [ ] get_aggregate_config_rule_compliance_summary
- [ ] get_aggregate_conformance_pack_compliance_summary - [ ] get_aggregate_conformance_pack_compliance_summary
@ -179,7 +179,7 @@ config
- [ ] put_remediation_configurations - [ ] put_remediation_configurations
- [ ] put_remediation_exceptions - [ ] put_remediation_exceptions
- [ ] put_resource_config - [ ] put_resource_config
- [ ] put_retention_configuration - [X] put_retention_configuration
- [ ] put_stored_query - [ ] put_stored_query
- [ ] select_aggregate_resource_config - [ ] select_aggregate_resource_config
- [ ] select_resource_config - [ ] select_resource_config

View File

@ -114,6 +114,14 @@ class InvalidS3KeyPrefixException(JsonRESTError):
super().__init__("InvalidS3KeyPrefixException", message) super().__init__("InvalidS3KeyPrefixException", message)
class InvalidS3KmsKeyArnException(JsonRESTError):
code = 400
def __init__(self) -> None:
message = "The arn '' is not a valid kms key or alias arn."
super().__init__("InvalidS3KmsKeyArnException", message)
class InvalidSNSTopicARNException(JsonRESTError): class InvalidSNSTopicARNException(JsonRESTError):
"""We are *only* validating that there is value that is not '' here.""" """We are *only* validating that there is value that is not '' here."""
@ -373,3 +381,13 @@ class MissingRequiredConfigRuleParameterException(JsonRESTError):
def __init__(self, message: str): def __init__(self, message: str):
super().__init__("ParamValidationError", message) super().__init__("ParamValidationError", message)
class NoSuchRetentionConfigurationException(JsonRESTError):
code = 400
def __init__(self, name: str):
message = (
f"Cannot find retention configuration with the specified name '{name}'."
)
super().__init__("NoSuchRetentionConfigurationException", message)

View File

@ -18,6 +18,7 @@ from moto.config.exceptions import (
InvalidDeliveryChannelNameException, InvalidDeliveryChannelNameException,
NoSuchBucketException, NoSuchBucketException,
InvalidS3KeyPrefixException, InvalidS3KeyPrefixException,
InvalidS3KmsKeyArnException,
InvalidSNSTopicARNException, InvalidSNSTopicARNException,
MaxNumberOfDeliveryChannelsExceededException, MaxNumberOfDeliveryChannelsExceededException,
NoAvailableDeliveryChannelException, NoAvailableDeliveryChannelException,
@ -44,6 +45,7 @@ from moto.config.exceptions import (
MaxNumberOfConfigRulesExceededException, MaxNumberOfConfigRulesExceededException,
InsufficientPermissionsException, InsufficientPermissionsException,
NoSuchConfigRuleException, NoSuchConfigRuleException,
NoSuchRetentionConfigurationException,
ResourceInUseException, ResourceInUseException,
MissingRequiredConfigRuleParameterException, MissingRequiredConfigRuleParameterException,
) )
@ -259,6 +261,7 @@ class ConfigDeliveryChannel(ConfigEmptyDictable):
s3_bucket_name: str, s3_bucket_name: str,
prefix: Optional[str] = None, prefix: Optional[str] = None,
sns_arn: Optional[str] = None, sns_arn: Optional[str] = None,
s3_kms_key_arn: Optional[str] = None,
snapshot_properties: Optional[ConfigDeliverySnapshotProperties] = None, snapshot_properties: Optional[ConfigDeliverySnapshotProperties] = None,
): ):
super().__init__() super().__init__()
@ -266,9 +269,21 @@ class ConfigDeliveryChannel(ConfigEmptyDictable):
self.name = name self.name = name
self.s3_bucket_name = s3_bucket_name self.s3_bucket_name = s3_bucket_name
self.s3_key_prefix = prefix self.s3_key_prefix = prefix
self.s3_kms_key_arn = s3_kms_key_arn
self.sns_topic_arn = sns_arn self.sns_topic_arn = sns_arn
self.config_snapshot_delivery_properties = snapshot_properties self.config_snapshot_delivery_properties = snapshot_properties
def to_dict(self) -> Dict[str, Any]:
"""Need to override this function because the KMS Key ARN is written as `Arn` vs. SNS which is `ARN`."""
data = super().to_dict()
# Fix the KMS ARN if it's here:
kms_arn = data.pop("s3KmsKeyARN", None)
if kms_arn:
data["s3KmsKeyArn"] = kms_arn
return data
class RecordingGroup(ConfigEmptyDictable): class RecordingGroup(ConfigEmptyDictable):
def __init__( def __init__(
@ -883,6 +898,14 @@ class ConfigRule(ConfigEmptyDictable):
# Verify the rule is allowed for this region -- not yet implemented. # Verify the rule is allowed for this region -- not yet implemented.
class RetentionConfiguration(ConfigEmptyDictable):
def __init__(self, retention_period_in_days: int, name: Optional[str] = None):
super().__init__(capitalize_start=True, capitalize_arn=False)
self.name = name or "default"
self.retention_period_in_days = retention_period_in_days
class ConfigBackend(BaseBackend): class ConfigBackend(BaseBackend):
def __init__(self, region_name: str, account_id: str): def __init__(self, region_name: str, account_id: str):
super().__init__(region_name, account_id) super().__init__(region_name, account_id)
@ -893,6 +916,7 @@ class ConfigBackend(BaseBackend):
self.organization_conformance_packs: Dict[str, OrganizationConformancePack] = {} self.organization_conformance_packs: Dict[str, OrganizationConformancePack] = {}
self.config_rules: Dict[str, ConfigRule] = {} self.config_rules: Dict[str, ConfigRule] = {}
self.config_schema: Optional[AWSServiceSpec] = None self.config_schema: Optional[AWSServiceSpec] = None
self.retention_configuration: Optional[RetentionConfiguration] = None
@staticmethod @staticmethod
def default_vpc_endpoint_service(service_region: str, zones: List[str]) -> List[Dict[str, Any]]: # type: ignore[misc] def default_vpc_endpoint_service(service_region: str, zones: List[str]) -> List[Dict[str, Any]]: # type: ignore[misc]
@ -1264,9 +1288,15 @@ class ConfigBackend(BaseBackend):
# Ditto for SNS -- Only going to assume that the ARN provided is not # Ditto for SNS -- Only going to assume that the ARN provided is not
# an empty string: # an empty string:
# NOTE: SNS "ARN" is all caps, but KMS "Arn" is UpperCamelCase!
if delivery_channel.get("snsTopicARN", None) == "": if delivery_channel.get("snsTopicARN", None) == "":
raise InvalidSNSTopicARNException() raise InvalidSNSTopicARNException()
# Ditto for S3 KMS Key ARN -- Only going to assume that the ARN provided is not
# an empty string:
if delivery_channel.get("s3KmsKeyArn", None) == "":
raise InvalidS3KmsKeyArnException()
# Config currently only allows 1 delivery channel for an account: # Config currently only allows 1 delivery channel for an account:
if len(self.delivery_channels) == 1 and not self.delivery_channels.get( if len(self.delivery_channels) == 1 and not self.delivery_channels.get(
delivery_channel["name"] delivery_channel["name"]
@ -1292,6 +1322,7 @@ class ConfigBackend(BaseBackend):
delivery_channel["name"], delivery_channel["name"],
delivery_channel["s3BucketName"], delivery_channel["s3BucketName"],
prefix=delivery_channel.get("s3KeyPrefix", None), prefix=delivery_channel.get("s3KeyPrefix", None),
s3_kms_key_arn=delivery_channel.get("s3KmsKeyArn", None),
sns_arn=delivery_channel.get("snsTopicARN", None), sns_arn=delivery_channel.get("snsTopicARN", None),
snapshot_properties=dprop, snapshot_properties=dprop,
) )
@ -2012,5 +2043,66 @@ class ConfigBackend(BaseBackend):
rule.config_rule_state = "DELETING" rule.config_rule_state = "DELETING"
self.config_rules.pop(rule_name) self.config_rules.pop(rule_name)
def put_retention_configuration(
self, retention_period_in_days: int
) -> Dict[str, Any]:
"""Creates a Retention Configuration."""
if retention_period_in_days < 30:
raise ValidationException(
f"Value '{retention_period_in_days}' at 'retentionPeriodInDays' failed to satisfy constraint: Member must have value greater than or equal to 30"
)
if retention_period_in_days > 2557:
raise ValidationException(
f"Value '{retention_period_in_days}' at 'retentionPeriodInDays' failed to satisfy constraint: Member must have value less than or equal to 2557"
)
self.retention_configuration = RetentionConfiguration(retention_period_in_days)
return {"RetentionConfiguration": self.retention_configuration.to_dict()}
def describe_retention_configurations(
self, retention_configuration_names: Optional[List[str]]
) -> List[Dict[str, Any]]:
"""
This will return the retention configuration if one is present.
This should only receive at most 1 name in. It will raise a ValidationException if more than 1 is supplied.
"""
# Handle the cases where we get a retention name to search for:
if retention_configuration_names:
if len(retention_configuration_names) > 1:
raise ValidationException(
f"Value '{retention_configuration_names}' at 'retentionConfigurationNames' failed to satisfy constraint: Member must have length less than or equal to 1"
)
# If we get a retention name to search for, and we don't have it, then we need to raise an exception:
if (
not self.retention_configuration
or not self.retention_configuration.name
== retention_configuration_names[0]
):
raise NoSuchRetentionConfigurationException(
retention_configuration_names[0]
)
# If we found it, then return it:
return [self.retention_configuration.to_dict()]
# If no name was supplied:
if self.retention_configuration:
return [self.retention_configuration.to_dict()]
return []
def delete_retention_configuration(self, retention_configuration_name: str) -> None:
"""This will delete the retention configuration if one is present with the provided name."""
if (
not self.retention_configuration
or not self.retention_configuration.name == retention_configuration_name
):
raise NoSuchRetentionConfigurationException(retention_configuration_name)
self.retention_configuration = None
config_backends = BackendDict(ConfigBackend, "config") config_backends = BackendDict(ConfigBackend, "config")

View File

@ -235,3 +235,24 @@ class ConfigResponse(BaseResponse):
def delete_config_rule(self) -> str: def delete_config_rule(self) -> str:
self.config_backend.delete_config_rule(self._get_param("ConfigRuleName")) self.config_backend.delete_config_rule(self._get_param("ConfigRuleName"))
return "" return ""
def put_retention_configuration(self) -> str:
retention_configuration = self.config_backend.put_retention_configuration(
self._get_param("RetentionPeriodInDays")
)
return json.dumps(retention_configuration)
def describe_retention_configurations(self) -> str:
retention_configurations = (
self.config_backend.describe_retention_configurations(
self._get_param("RetentionConfigurationNames")
)
)
schema = {"RetentionConfigurations": retention_configurations}
return json.dumps(schema)
def delete_retention_configuration(self) -> str:
self.config_backend.delete_retention_configuration(
self._get_param("RetentionConfigurationName")
)
return ""

View File

@ -4,6 +4,7 @@ import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
import boto3 import boto3
from botocore.config import Config
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from unittest import SkipTest from unittest import SkipTest
import pytest import pytest
@ -879,6 +880,21 @@ def test_delivery_channels():
assert ce.value.response["Error"]["Code"] == "InvalidSNSTopicARNException" assert ce.value.response["Error"]["Code"] == "InvalidSNSTopicARNException"
assert "The sns topic arn" in ce.value.response["Error"]["Message"] assert "The sns topic arn" in ce.value.response["Error"]["Message"]
# With an empty string for the S3 KMS Key ARN:
with pytest.raises(ClientError) as ce:
client.put_delivery_channel(
DeliveryChannel={
"name": "testchannel",
"s3BucketName": "somebucket",
"s3KmsKeyArn": "",
}
)
assert ce.value.response["Error"]["Code"] == "InvalidS3KmsKeyArnException"
assert (
ce.value.response["Error"]["Message"]
== "The arn '' is not a valid kms key or alias arn."
)
# With an invalid delivery frequency: # With an invalid delivery frequency:
with pytest.raises(ClientError) as ce: with pytest.raises(ClientError) as ce:
client.put_delivery_channel( client.put_delivery_channel(
@ -973,6 +989,7 @@ def test_describe_delivery_channels():
"name": "testchannel", "name": "testchannel",
"s3BucketName": "somebucket", "s3BucketName": "somebucket",
"snsTopicARN": "sometopicarn", "snsTopicARN": "sometopicarn",
"s3KmsKeyArn": "somekmsarn",
"configSnapshotDeliveryProperties": { "configSnapshotDeliveryProperties": {
"deliveryFrequency": "TwentyFour_Hours" "deliveryFrequency": "TwentyFour_Hours"
}, },
@ -980,10 +997,11 @@ def test_describe_delivery_channels():
) )
result = client.describe_delivery_channels()["DeliveryChannels"] result = client.describe_delivery_channels()["DeliveryChannels"]
assert len(result) == 1 assert len(result) == 1
assert len(result[0].keys()) == 4 assert len(result[0].keys()) == 5
assert result[0]["name"] == "testchannel" assert result[0]["name"] == "testchannel"
assert result[0]["s3BucketName"] == "somebucket" assert result[0]["s3BucketName"] == "somebucket"
assert result[0]["snsTopicARN"] == "sometopicarn" assert result[0]["snsTopicARN"] == "sometopicarn"
assert result[0]["s3KmsKeyArn"] == "somekmsarn"
assert ( assert (
result[0]["configSnapshotDeliveryProperties"]["deliveryFrequency"] result[0]["configSnapshotDeliveryProperties"]["deliveryFrequency"]
== "TwentyFour_Hours" == "TwentyFour_Hours"
@ -2148,3 +2166,109 @@ def test_delete_organization_conformance_pack_errors():
ex.response["Error"]["Message"].should.equal( ex.response["Error"]["Message"].should.equal(
"Could not find an OrganizationConformancePack for given request with resourceName not-existing" "Could not find an OrganizationConformancePack for given request with resourceName not-existing"
) )
@mock_config
def test_put_retention_configuration():
# Test with parameter validation being False to test the retention days check:
client = boto3.client(
"config",
region_name="us-west-2",
config=Config(parameter_validation=False, user_agent_extra="moto"),
)
# Less than 30 days:
with pytest.raises(ClientError) as ce:
client.put_retention_configuration(RetentionPeriodInDays=29)
assert (
ce.value.response["Error"]["Message"]
== "Value '29' at 'retentionPeriodInDays' failed to satisfy constraint: Member must have value greater than or equal to 30"
)
# More than 2557 days:
with pytest.raises(ClientError) as ce:
client.put_retention_configuration(RetentionPeriodInDays=2558)
assert (
ce.value.response["Error"]["Message"]
== "Value '2558' at 'retentionPeriodInDays' failed to satisfy constraint: Member must have value less than or equal to 2557"
)
# No errors:
result = client.put_retention_configuration(RetentionPeriodInDays=2557)
assert result["RetentionConfiguration"] == {
"Name": "default",
"RetentionPeriodInDays": 2557,
}
@mock_config
def test_describe_retention_configurations():
client = boto3.client("config", region_name="us-west-2")
# Without any recorder configurations:
result = client.describe_retention_configurations()
assert not result["RetentionConfigurations"]
# Create one and then describe:
client.put_retention_configuration(RetentionPeriodInDays=2557)
result = client.describe_retention_configurations()
assert result["RetentionConfigurations"] == [
{"Name": "default", "RetentionPeriodInDays": 2557}
]
# Describe with the correct name:
result = client.describe_retention_configurations(
RetentionConfigurationNames=["default"]
)
assert result["RetentionConfigurations"] == [
{"Name": "default", "RetentionPeriodInDays": 2557}
]
# Describe with more than 1 configuration name provided:
with pytest.raises(ClientError) as ce:
client.describe_retention_configurations(
RetentionConfigurationNames=["bad", "entry"]
)
assert (
ce.value.response["Error"]["Message"]
== "Value '['bad', 'entry']' at 'retentionConfigurationNames' failed to satisfy constraint: "
"Member must have length less than or equal to 1"
)
# Describe with a name that's not default:
with pytest.raises(ClientError) as ce:
client.describe_retention_configurations(
RetentionConfigurationNames=["notfound"]
)
assert (
ce.value.response["Error"]["Message"]
== "Cannot find retention configuration with the specified name 'notfound'."
)
@mock_config
def test_delete_retention_configuration():
client = boto3.client("config", region_name="us-west-2")
# Create one first:
client.put_retention_configuration(RetentionPeriodInDays=2557)
# Delete with a name that's not default:
with pytest.raises(ClientError) as ce:
client.delete_retention_configuration(RetentionConfigurationName="notfound")
assert (
ce.value.response["Error"]["Message"]
== "Cannot find retention configuration with the specified name 'notfound'."
)
# Delete it properly:
client.delete_retention_configuration(RetentionConfigurationName="default")
assert not client.describe_retention_configurations()["RetentionConfigurations"]
# And again...
with pytest.raises(ClientError) as ce:
client.delete_retention_configuration(RetentionConfigurationName="default")
assert (
ce.value.response["Error"]["Message"]
== "Cannot find retention configuration with the specified name 'default'."
)