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_exceptions
- [ ] delete_resource_config
- [ ] delete_retention_configuration
- [X] delete_retention_configuration
- [ ] delete_stored_query
- [ ] deliver_config_snapshot
- [ ] describe_aggregate_compliance_by_config_rules
@ -91,7 +91,7 @@ config
- [ ] describe_remediation_configurations
- [ ] describe_remediation_exceptions
- [ ] describe_remediation_execution_status
- [ ] describe_retention_configurations
- [X] describe_retention_configurations
- [ ] get_aggregate_compliance_details_by_config_rule
- [ ] get_aggregate_config_rule_compliance_summary
- [ ] get_aggregate_conformance_pack_compliance_summary
@ -179,7 +179,7 @@ config
- [ ] put_remediation_configurations
- [ ] put_remediation_exceptions
- [ ] put_resource_config
- [ ] put_retention_configuration
- [X] put_retention_configuration
- [ ] put_stored_query
- [ ] select_aggregate_resource_config
- [ ] select_resource_config

View File

@ -114,6 +114,14 @@ class InvalidS3KeyPrefixException(JsonRESTError):
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):
"""We are *only* validating that there is value that is not '' here."""
@ -373,3 +381,13 @@ class MissingRequiredConfigRuleParameterException(JsonRESTError):
def __init__(self, message: str):
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,
NoSuchBucketException,
InvalidS3KeyPrefixException,
InvalidS3KmsKeyArnException,
InvalidSNSTopicARNException,
MaxNumberOfDeliveryChannelsExceededException,
NoAvailableDeliveryChannelException,
@ -44,6 +45,7 @@ from moto.config.exceptions import (
MaxNumberOfConfigRulesExceededException,
InsufficientPermissionsException,
NoSuchConfigRuleException,
NoSuchRetentionConfigurationException,
ResourceInUseException,
MissingRequiredConfigRuleParameterException,
)
@ -259,6 +261,7 @@ class ConfigDeliveryChannel(ConfigEmptyDictable):
s3_bucket_name: str,
prefix: Optional[str] = None,
sns_arn: Optional[str] = None,
s3_kms_key_arn: Optional[str] = None,
snapshot_properties: Optional[ConfigDeliverySnapshotProperties] = None,
):
super().__init__()
@ -266,9 +269,21 @@ class ConfigDeliveryChannel(ConfigEmptyDictable):
self.name = name
self.s3_bucket_name = s3_bucket_name
self.s3_key_prefix = prefix
self.s3_kms_key_arn = s3_kms_key_arn
self.sns_topic_arn = sns_arn
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):
def __init__(
@ -883,6 +898,14 @@ class ConfigRule(ConfigEmptyDictable):
# 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):
def __init__(self, region_name: str, account_id: str):
super().__init__(region_name, account_id)
@ -893,6 +916,7 @@ class ConfigBackend(BaseBackend):
self.organization_conformance_packs: Dict[str, OrganizationConformancePack] = {}
self.config_rules: Dict[str, ConfigRule] = {}
self.config_schema: Optional[AWSServiceSpec] = None
self.retention_configuration: Optional[RetentionConfiguration] = None
@staticmethod
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
# an empty string:
# NOTE: SNS "ARN" is all caps, but KMS "Arn" is UpperCamelCase!
if delivery_channel.get("snsTopicARN", None) == "":
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:
if len(self.delivery_channels) == 1 and not self.delivery_channels.get(
delivery_channel["name"]
@ -1292,6 +1322,7 @@ class ConfigBackend(BaseBackend):
delivery_channel["name"],
delivery_channel["s3BucketName"],
prefix=delivery_channel.get("s3KeyPrefix", None),
s3_kms_key_arn=delivery_channel.get("s3KmsKeyArn", None),
sns_arn=delivery_channel.get("snsTopicARN", None),
snapshot_properties=dprop,
)
@ -2012,5 +2043,66 @@ class ConfigBackend(BaseBackend):
rule.config_rule_state = "DELETING"
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")

View File

@ -235,3 +235,24 @@ class ConfigResponse(BaseResponse):
def delete_config_rule(self) -> str:
self.config_backend.delete_config_rule(self._get_param("ConfigRuleName"))
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
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from unittest import SkipTest
import pytest
@ -879,6 +880,21 @@ def test_delivery_channels():
assert ce.value.response["Error"]["Code"] == "InvalidSNSTopicARNException"
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 pytest.raises(ClientError) as ce:
client.put_delivery_channel(
@ -973,6 +989,7 @@ def test_describe_delivery_channels():
"name": "testchannel",
"s3BucketName": "somebucket",
"snsTopicARN": "sometopicarn",
"s3KmsKeyArn": "somekmsarn",
"configSnapshotDeliveryProperties": {
"deliveryFrequency": "TwentyFour_Hours"
},
@ -980,10 +997,11 @@ def test_describe_delivery_channels():
)
result = client.describe_delivery_channels()["DeliveryChannels"]
assert len(result) == 1
assert len(result[0].keys()) == 4
assert len(result[0].keys()) == 5
assert result[0]["name"] == "testchannel"
assert result[0]["s3BucketName"] == "somebucket"
assert result[0]["snsTopicARN"] == "sometopicarn"
assert result[0]["s3KmsKeyArn"] == "somekmsarn"
assert (
result[0]["configSnapshotDeliveryProperties"]["deliveryFrequency"]
== "TwentyFour_Hours"
@ -2148,3 +2166,109 @@ def test_delete_organization_conformance_pack_errors():
ex.response["Error"]["Message"].should.equal(
"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'."
)