From 735eac87b17441ffda6d9cf0a93fd869d2a508aa Mon Sep 17 00:00:00 2001 From: Anton Kuznetsov <72220685+anton-aws@users.noreply.github.com> Date: Wed, 16 Dec 2020 12:06:00 -0800 Subject: [PATCH] Add support for AWS::IoT::SecurityProfile (#19) * Add support for AWS::IoT::SecurityProfile * Add a rate limiter for attach&detach calls * Remove the deprecated AdditionalMetrics (V1) field * Fix typo in list handler permissions * Remove accidental requirement for name in json schema * Use ProgressEvents instead of Cfn exceptions * Fix accidentally commented out test * Add minor improvements --- aws-iot-securityprofile/.gitignore | 23 ++ aws-iot-securityprofile/.rpdk-config | 22 + aws-iot-securityprofile/README.md | 19 + .../aws-iot-securityprofile.json | 345 ++++++++++++++++ .../inputs/inputs_1_create.json | 32 ++ .../inputs/inputs_1_invalid.json | 9 + .../inputs/inputs_1_update.json | 31 ++ aws-iot-securityprofile/lombok.config | 1 + aws-iot-securityprofile/pom.xml | 210 ++++++++++ aws-iot-securityprofile/resource-role.yaml | 42 ++ .../iot/securityprofile/CallbackContext.java | 10 + .../iot/securityprofile/Configuration.java | 21 + .../iot/securityprofile/CreateHandler.java | 118 ++++++ .../iot/securityprofile/DeleteHandler.java | 60 +++ .../iot/securityprofile/HandlerUtils.java | 60 +++ .../iot/securityprofile/ListHandler.java | 57 +++ .../iot/securityprofile/ReadHandler.java | 109 +++++ .../iot/securityprofile/Translator.java | 344 ++++++++++++++++ .../iot/securityprofile/UpdateHandler.java | 252 ++++++++++++ .../securityprofile/CreateHandlerTest.java | 240 +++++++++++ .../securityprofile/DeleteHandlerTest.java | 117 ++++++ .../iot/securityprofile/HandlerUtilsTest.java | 126 ++++++ .../iot/securityprofile/ListHandlerTest.java | 102 +++++ .../iot/securityprofile/ReadHandlerTest.java | 162 ++++++++ .../iot/securityprofile/TestConstants.java | 117 ++++++ .../iot/securityprofile/TranslatorTest.java | 185 +++++++++ .../securityprofile/UpdateHandlerTest.java | 383 ++++++++++++++++++ aws-iot-securityprofile/template.yml | 23 ++ 28 files changed, 3220 insertions(+) create mode 100644 aws-iot-securityprofile/.gitignore create mode 100644 aws-iot-securityprofile/.rpdk-config create mode 100644 aws-iot-securityprofile/README.md create mode 100644 aws-iot-securityprofile/aws-iot-securityprofile.json create mode 100644 aws-iot-securityprofile/inputs/inputs_1_create.json create mode 100644 aws-iot-securityprofile/inputs/inputs_1_invalid.json create mode 100644 aws-iot-securityprofile/inputs/inputs_1_update.json create mode 100644 aws-iot-securityprofile/lombok.config create mode 100644 aws-iot-securityprofile/pom.xml create mode 100644 aws-iot-securityprofile/resource-role.yaml create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CallbackContext.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Configuration.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CreateHandler.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/DeleteHandler.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/HandlerUtils.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ListHandler.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ReadHandler.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Translator.java create mode 100644 aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/UpdateHandler.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/CreateHandlerTest.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/DeleteHandlerTest.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/HandlerUtilsTest.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ListHandlerTest.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ReadHandlerTest.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TestConstants.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TranslatorTest.java create mode 100644 aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/UpdateHandlerTest.java create mode 100644 aws-iot-securityprofile/template.yml diff --git a/aws-iot-securityprofile/.gitignore b/aws-iot-securityprofile/.gitignore new file mode 100644 index 0000000..5eb6238 --- /dev/null +++ b/aws-iot-securityprofile/.gitignore @@ -0,0 +1,23 @@ +# macOS +.DS_Store +._* + +# Maven outputs +.classpath + +# IntelliJ +*.iml +.idea +out.java +out/ +.settings +.project + +# auto-generated files +target/ + +# our logs +rpdk.log + +# contains credentials +sam-tests/ diff --git a/aws-iot-securityprofile/.rpdk-config b/aws-iot-securityprofile/.rpdk-config new file mode 100644 index 0000000..6626f13 --- /dev/null +++ b/aws-iot-securityprofile/.rpdk-config @@ -0,0 +1,22 @@ +{ + "typeName": "AWS::IoT::SecurityProfile", + "language": "java", + "runtime": "java8", + "entrypoint": "com.amazonaws.iot.securityprofile.HandlerWrapper::handleRequest", + "testEntrypoint": "com.amazonaws.iot.securityprofile.HandlerWrapper::testEntrypoint", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": null, + "namespace": [ + "com", + "amazonaws", + "iot", + "securityprofile" + ], + "codegen_template_path": "guided_aws", + "protocolVersion": "2.0.0" + } +} diff --git a/aws-iot-securityprofile/README.md b/aws-iot-securityprofile/README.md new file mode 100644 index 0000000..7b69564 --- /dev/null +++ b/aws-iot-securityprofile/README.md @@ -0,0 +1,19 @@ +# AWS::IoT::SecurityProfile + +## Running Contract Tests + +You can execute the following commands to run the tests. +You will need to have docker installed and running. + +```bash +# Package the code with Maven +mvn package +# Start SAM which will execute lambdas in Docker +sam local start-lambda + +# In a separate terminal, run the contract tests +cfn test --enforce-timeout 240 + +# Execute a single test +cfn test --enforce-timeout 240 -- -k +``` diff --git a/aws-iot-securityprofile/aws-iot-securityprofile.json b/aws-iot-securityprofile/aws-iot-securityprofile.json new file mode 100644 index 0000000..9327c25 --- /dev/null +++ b/aws-iot-securityprofile/aws-iot-securityprofile.json @@ -0,0 +1,345 @@ +{ + "typeName": "AWS::IoT::SecurityProfile", + "description": "A security profile defines a set of expected behaviors for devices in your account.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iot.git", + "definitions": { + "Behavior": { + "description": "A security profile behavior.", + "type": "object", + "properties": { + "Name": { + "description": "The name for the behavior.", + "type": "string", + "pattern": "[a-zA-Z0-9:_-]+", + "minLength": 1, + "maxLength": 128 + }, + "Metric": { + "description": "What is measured by the behavior.", + "type": "string", + "pattern": "[a-zA-Z0-9:_-]+", + "minLength": 1, + "maxLength": 128 + }, + "MetricDimension": { + "$ref": "#/definitions/MetricDimension" + }, + "Criteria": { + "$ref": "#/definitions/BehaviorCriteria" + } + }, + "required": [ + "Name" + ], + "additionalProperties": false + }, + "MetricDimension": { + "description": "The dimension of a metric.", + "type": "object", + "properties": { + "DimensionName": { + "description": "A unique identifier for the dimension.", + "type": "string", + "pattern": "[a-zA-Z0-9:_-]+", + "minLength": 1, + "maxLength": 128 + }, + "Operator": { + "description": "Defines how the dimensionValues of a dimension are interpreted.", + "type": "string", + "enum": [ + "IN", + "NOT_IN" + ] + } + }, + "required": [ + "DimensionName" + ], + "additionalProperties": false + }, + "BehaviorCriteria": { + "description": "The criteria by which the behavior is determined to be normal.", + "type": "object", + "properties": { + "ComparisonOperator": { + "description": "The operator that relates the thing measured (metric) to the criteria (containing a value or statisticalThreshold).", + "type": "string", + "enum": [ + "less-than", + "less-than-equals", + "greater-than", + "greater-than-equals", + "in-cidr-set", + "not-in-cidr-set", + "in-port-set", + "not-in-port-set" + ] + }, + "Value": { + "$ref": "#/definitions/MetricValue" + }, + "DurationSeconds": { + "type": "integer", + "description": "Use this to specify the time duration over which the behavior is evaluated." + }, + "ConsecutiveDatapointsToAlarm": { + "description": "If a device is in violation of the behavior for the specified number of consecutive datapoints, an alarm occurs. If not specified, the default is 1.", + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "ConsecutiveDatapointsToClear": { + "description": "If an alarm has occurred and the offending device is no longer in violation of the behavior for the specified number of consecutive datapoints, the alarm is cleared. If not specified, the default is 1.", + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "StatisticalThreshold": { + "$ref": "#/definitions/StatisticalThreshold" + } + }, + "additionalProperties": false + }, + "MetricValue": { + "description": "The value to be compared with the metric.", + "type": "object", + "properties": { + "Count": { + "description": "If the ComparisonOperator calls for a numeric value, use this to specify that (integer) numeric value to be compared with the metric.", + "type": "string", + "minimum": 0 + }, + "Cidrs": { + "description": "If the ComparisonOperator calls for a set of CIDRs, use this to specify that set to be compared with the metric.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "Ports": { + "description": "If the ComparisonOperator calls for a set of ports, use this to specify that set to be compared with the metric.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + }, + "additionalProperties": false + }, + "StatisticalThreshold": { + "description": "A statistical ranking (percentile) which indicates a threshold value by which a behavior is determined to be in compliance or in violation of the behavior.", + "type": "object", + "properties": { + "Statistic": { + "description": "The percentile which resolves to a threshold value by which compliance with a behavior is determined", + "type": "string", + "enum": [ + "Average", + "p0", + "p0.1", + "p0.01", + "p1", + "p10", + "p50", + "p90", + "p99", + "p99.9", + "p99.99", + "p100" + ] + } + }, + "additionalProperties": false + }, + "AlertTarget": { + "description": "A structure containing the alert target ARN and the role ARN.", + "type": "object", + "properties": { + "AlertTargetArn": { + "description": "The ARN of the notification target to which alerts are sent.", + "type": "string", + "maxLength": 2048 + }, + "RoleArn": { + "description": "The ARN of the role that grants permission to send alerts to the notification target.", + "type": "string", + "minLength": 20, + "maxLength": 2048 + } + }, + "required": [ + "AlertTargetArn", + "RoleArn" + ], + "additionalProperties": false + }, + "MetricToRetain": { + "description": "The metric you want to retain. Dimensions are optional.", + "type": "object", + "properties": { + "Metric": { + "description": "What is measured by the behavior.", + "type": "string", + "pattern": "[a-zA-Z0-9:_-]+", + "minLength": 1, + "maxLength": 128 + }, + "MetricDimension": { + "$ref": "#/definitions/MetricDimension" + } + }, + "required": [ + "Metric" + ], + "additionalProperties": false + }, + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The tag's key.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The tag's value.", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ], + "additionalProperties": false + } + }, + "properties": { + "SecurityProfileName": { + "description": "A unique identifier for the security profile.", + "type": "string", + "pattern": "[a-zA-Z0-9:_-]+", + "minLength": 1, + "maxLength": 128 + }, + "SecurityProfileDescription": { + "description": "A description of the security profile.", + "type": "string", + "maxLength": 1000 + }, + "Behaviors": { + "description": "Specifies the behaviors that, when violated by a device (thing), cause an alert.", + "type": "array", + "maxLength": 100, + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Behavior" + } + }, + "AlertTargets": { + "description": "Specifies the destinations to which alerts are sent.", + "type": "object", + "patternProperties": { + "[a-zA-Z0-9:_-]+": { + "$ref": "#/definitions/AlertTarget" + } + }, + "additionalProperties": false + }, + "AdditionalMetricsToRetainV2": { + "description": "A list of metrics whose data is retained (stored). By default, data is retained for any metric used in the profile's behaviors, but it is also retained for any metric specified here.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/MetricToRetain" + } + }, + "Tags": { + "description": "Metadata that can be used to manage the security profile.", + "type": "array", + "maxItems": 50, + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "TargetArns": { + "description": "A set of target ARNs that the security profile is attached to.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "description": "The ARN of the target to which the security profile is attached.", + "type": "string", + "maxLength": 2048 + } + }, + "SecurityProfileArn": { + "description": "The ARN (Amazon resource name) of the created security profile.", + "type": "string" + } + }, + "additionalProperties": false, + "primaryIdentifier": [ + "/properties/SecurityProfileName" + ], + "required": [], + "createOnlyProperties": [ + "/properties/SecurityProfileName" + ], + "readOnlyProperties": [ + "/properties/SecurityProfileArn" + ], + "handlers": { + "create": { + "permissions": [ + "iot:CreateSecurityProfile", + "iot:AttachSecurityProfile", + "iam:PassRole" + ] + }, + "read": { + "permissions": [ + "iot:DescribeSecurityProfile", + "iot:ListTagsForResource", + "iot:ListTargetsForSecurityProfile" + ] + }, + "update": { + "permissions": [ + "iot:UpdateSecurityProfile", + "iot:ListTargetsForSecurityProfile", + "iot:AttachSecurityProfile", + "iot:DetachSecurityProfile", + "iot:ListTagsForResource", + "iot:UntagResource", + "iot:TagResource", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "iot:DescribeSecurityProfile", + "iot:DeleteSecurityProfile" + ] + }, + "list": { + "permissions": [ + "iot:ListSecurityProfiles" + ] + } + } +} diff --git a/aws-iot-securityprofile/inputs/inputs_1_create.json b/aws-iot-securityprofile/inputs/inputs_1_create.json new file mode 100644 index 0000000..5448b6c --- /dev/null +++ b/aws-iot-securityprofile/inputs/inputs_1_create.json @@ -0,0 +1,32 @@ +{ + "SecurityProfileName": "CfnContractTest", + "SecurityProfileDescription": "A valid security profile 1!@#$%^&*()", + "Behaviors": [ + { + "Name": "Behavior1:_-", + "Metric": "aws:listening-tcp-ports", + "Criteria": { + "ComparisonOperator": "in-port-set", + "ConsecutiveDatapointsToAlarm": 1, + "ConsecutiveDatapointsToClear": 1, + "Value": { + "Ports": [ + 443 + ] + } + } + } + ], + "AdditionalMetricsToRetainV2": [ + { + "Metric": "aws:num-messages-sent" + } + ], + "TargetArns": [], + "Tags": [ + { + "Key": "testTagKey", + "Value": "tagValue" + } + ] +} diff --git a/aws-iot-securityprofile/inputs/inputs_1_invalid.json b/aws-iot-securityprofile/inputs/inputs_1_invalid.json new file mode 100644 index 0000000..98d77ba --- /dev/null +++ b/aws-iot-securityprofile/inputs/inputs_1_invalid.json @@ -0,0 +1,9 @@ +{ + "SecurityProfileName": "CfnContractTest-Invalid", + "AdditionalMetricsToRetainV2": [ + { + "Metric": "aws:num-messages-sent" + } + ], + "SecurityProfileArn": "Arn is a read-only property" +} diff --git a/aws-iot-securityprofile/inputs/inputs_1_update.json b/aws-iot-securityprofile/inputs/inputs_1_update.json new file mode 100644 index 0000000..ddbaefb --- /dev/null +++ b/aws-iot-securityprofile/inputs/inputs_1_update.json @@ -0,0 +1,31 @@ +{ + "SecurityProfileName": "CfnContractTest", + "SecurityProfileDescription": "A valid security profile 1!@#$%^&*()", + "Behaviors": [ + { + "Name": "Behavior2:_-", + "Metric": "aws:source-ip-address", + "Criteria": { + "ComparisonOperator": "in-cidr-set", + "ConsecutiveDatapointsToAlarm": 2, + "ConsecutiveDatapointsToClear": 2, + "Value": { + "Cidrs": [ + "192.168.100.14/24" + ] + } + } + } + ], + "AdditionalMetricsToRetainV2": [ + { + "Metric": "aws:listening-tcp-ports" + } + ], + "Tags": [ + { + "Key": "testTagKey", + "Value": "tagValue" + } + ] +} diff --git a/aws-iot-securityprofile/lombok.config b/aws-iot-securityprofile/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/aws-iot-securityprofile/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/aws-iot-securityprofile/pom.xml b/aws-iot-securityprofile/pom.xml new file mode 100644 index 0000000..4f219de --- /dev/null +++ b/aws-iot-securityprofile/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + com.amazonaws.iot.securityprofile + aws-iot-securityprofile-handler + aws-iot-securityprofile-handler + 1.0-SNAPSHOT + jar + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + + + + software.amazon.awssdk + iot + 2.15.7 + + + + software.amazon.cloudformation + aws-cloudformation-rpdk-java-plugin + [2.0.0,3.0.0) + + + + org.projectlombok + lombok + 1.18.4 + provided + + + + + org.assertj + assertj-core + 3.12.2 + test + + + + org.junit.jupiter + junit-jupiter + 5.5.0-M1 + test + + + + org.mockito + mockito-core + 2.26.0 + test + + + + org.mockito + mockito-junit-jupiter + 2.26.0 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-processing + -Werror + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + false + + + + package + + shade + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + generate + generate-sources + + exec + + + cfn + generate + ${project.basedir} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/rpdk + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4 + + + maven-surefire-plugin + 3.0.0-M3 + + + org.jacoco + jacoco-maven-plugin + 0.8.4 + + + **/BaseConfiguration* + **/BaseHandler* + **/HandlerWrapper* + **/ResourceModel* + + + + + + prepare-agent + + + + report + test + + report + + + + jacoco-check + + check + + + + + PACKAGE + + + BRANCH + COVEREDRATIO + 0.8 + + + INSTRUCTION + COVEREDRATIO + 0.8 + + + + + + + + + + + + ${project.basedir} + + aws-iot-securityprofile.json + + + + + diff --git a/aws-iot-securityprofile/resource-role.yaml b/aws-iot-securityprofile/resource-role.yaml new file mode 100644 index 0000000..38675bd --- /dev/null +++ b/aws-iot-securityprofile/resource-role.yaml @@ -0,0 +1,42 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during CRUDL operations to mutate resources on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "iam:PassRole" + - "iot:AttachSecurityProfile" + - "iot:CreateSecurityProfile" + - "iot:DeleteSecurityProfile" + - "iot:DescribeSecurityProfile" + - "iot:DetachSecurityProfile" + - "iot:ListSecurityProfiles" + - "iot:ListTagsForResource" + - "iot:ListTargetsForSecurityProfile" + - "iot:TagResource" + - "iot:UntagResource" + - "iot:UpdateSecurityProfile" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CallbackContext.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CallbackContext.java new file mode 100644 index 0000000..245e8a3 --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CallbackContext.java @@ -0,0 +1,10 @@ +package com.amazonaws.iot.securityprofile; + +import software.amazon.cloudformation.proxy.StdCallbackContext; + +@lombok.Getter +@lombok.Setter +@lombok.ToString +@lombok.EqualsAndHashCode(callSuper = true) +public class CallbackContext extends StdCallbackContext { +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Configuration.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Configuration.java new file mode 100644 index 0000000..9c47566 --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Configuration.java @@ -0,0 +1,21 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.Map; +import java.util.stream.Collectors; + +class Configuration extends BaseConfiguration { + + public Configuration() { + super("aws-iot-securityprofile.json"); + } + + @Override + public Map resourceDefinedTags(ResourceModel resourceModel) { + if (resourceModel.getTags() == null) { + return null; + } else { + return resourceModel.getTags().stream() + .collect(Collectors.toMap(Tag::getKey, Tag::getValue)); + } + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CreateHandler.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CreateHandler.java new file mode 100644 index 0000000..6a849f6 --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/CreateHandler.java @@ -0,0 +1,118 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.Map; +import java.util.Set; + +import com.google.common.util.concurrent.RateLimiter; + +import org.apache.commons.lang3.StringUtils; +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.AttachSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.CreateSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.CreateSecurityProfileResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; +import software.amazon.cloudformation.resource.IdentifierUtils; + +public class CreateHandler extends BaseHandler { + + // Copied value from software.amazon.cloudformation.resource.IdentifierUtils + private static final int GENERATED_NAME_MAX_LENGTH = 40; + private static final int MAX_CALLS_PER_SECOND_LIMIT = 5; + + private final IotClient iotClient; + + public CreateHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + CreateSecurityProfileRequest createRequest = translateToCreateRequest(request); + + ResourceModel model = request.getDesiredResourceState(); + if (!StringUtils.isEmpty(model.getSecurityProfileArn())) { + logger.log(String.format("Arn is read-only, but the caller passed %s.", model.getSecurityProfileArn())); + // Note: this is necessary even though Arn is marked readOnly in the schema. + return ProgressEvent.failed(model, callbackContext, HandlerErrorCode.InvalidRequest, + "Arn is a read-only property and cannot be set."); + } + + CreateSecurityProfileResponse createResponse; + try { + createResponse = proxy.injectCredentialsAndInvokeV2( + createRequest, iotClient::createSecurityProfile); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + + model.setSecurityProfileArn(createResponse.securityProfileArn()); + logger.log("Created " + createResponse.securityProfileArn()); + + // We're letting customers manage Security Profile attachments in the same CFN template, + // using the TargetArns field. Thus, we need to make an AttachSecurityProfile call for every target. + Set targetArns = model.getTargetArns(); + if (targetArns != null) { + // The number of targets can be large, we need to avoid getting throttled. + RateLimiter rateLimiter = RateLimiter.create(MAX_CALLS_PER_SECOND_LIMIT); + for (String targetArn : targetArns) { + rateLimiter.acquire(); + + AttachSecurityProfileRequest attachRequest = AttachSecurityProfileRequest.builder() + .securityProfileName(model.getSecurityProfileName()) + .securityProfileTargetArn(targetArn) + .build(); + try { + proxy.injectCredentialsAndInvokeV2(attachRequest, iotClient::attachSecurityProfile); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + logger.log("Attached the security profile to " + targetArn); + } + } + + return ProgressEvent.defaultSuccessHandler(model); + } + + private CreateSecurityProfileRequest translateToCreateRequest( + ResourceHandlerRequest request) { + + ResourceModel model = request.getDesiredResourceState(); + + // Like most services, we don't require an explicit resource name in the template, + // and, if it's not provided, generate one based on the stack ID and logical ID. + if (StringUtils.isBlank(model.getSecurityProfileName())) { + model.setSecurityProfileName(IdentifierUtils.generateResourceIdentifier( + request.getStackId(), request.getLogicalResourceIdentifier(), + request.getClientRequestToken(), GENERATED_NAME_MAX_LENGTH)); + } + + // getDesiredResourceTags combines the model and stack-level tags. + // Reference: https://tinyurl.com/yyxtd7w6 + Map tags = request.getDesiredResourceTags(); + // TODO: uncomment this after we update the service to allow these (only from CFN) + // SystemTags are the default stack-level tags with aws:cloudformation prefix + // tags.putAll(request.getSystemTags()); + + // Note that the handlers act as pass-through in terms of input validation. + // We have some validations in the json model, but we delegate deeper checks to the service. + // If there's an invalid input, we'll rethrow the service's InvalidRequestException with a readable message. + return CreateSecurityProfileRequest.builder() + .securityProfileName(model.getSecurityProfileName()) + .securityProfileDescription(model.getSecurityProfileDescription()) + .behaviors(Translator.translateBehaviorSetFromCfnToIot(model.getBehaviors())) + .alertTargetsWithStrings(Translator.translateAlertTargetMapFromCfnToIot(model.getAlertTargets())) + .additionalMetricsToRetainV2(Translator.translateMetricToRetainSetFromCfnToIot( + model.getAdditionalMetricsToRetainV2())) + .tags(Translator.translateTagsFromCfnToIot(tags)) + .build(); + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/DeleteHandler.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/DeleteHandler.java new file mode 100644 index 0000000..77e108b --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/DeleteHandler.java @@ -0,0 +1,60 @@ +package com.amazonaws.iot.securityprofile; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DeleteSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileRequest; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandler extends BaseHandler { + + private final IotClient iotClient; + + public DeleteHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel model = request.getDesiredResourceState(); + + // From https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-test-contract.html + // "A delete handler MUST return FAILED with a NotFound error code if the + // resource did not exist prior to the delete request." + // DeleteSecurityProfile API is idempotent, so we have to call Describe first. + DescribeSecurityProfileRequest describeRequest = DescribeSecurityProfileRequest.builder() + .securityProfileName(model.getSecurityProfileName()) + .build(); + try { + proxy.injectCredentialsAndInvokeV2(describeRequest, iotClient::describeSecurityProfile); + } catch (Exception e) { + // If the resource doesn't exist, DescribeSecurityProfile will throw NotFoundException, + // and we'll return FAILED with HandlerErrorCode.NotFound. + // CFN (the caller) will swallow the "failure" and the customer will see success. + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + logger.log(String.format("Called Describe for %s with name %s, accountId %s.", + ResourceModel.TYPE_NAME, model.getSecurityProfileName(), request.getAwsAccountId())); + + DeleteSecurityProfileRequest deleteRequest = DeleteSecurityProfileRequest.builder() + .securityProfileName(model.getSecurityProfileName()) + .build(); + try { + proxy.injectCredentialsAndInvokeV2(deleteRequest, iotClient::deleteSecurityProfile); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + + logger.log(String.format("Deleted %s with name %s, accountId %s.", + ResourceModel.TYPE_NAME, model.getSecurityProfileName(), request.getAwsAccountId())); + + return ProgressEvent.defaultSuccessHandler(null); + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/HandlerUtils.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/HandlerUtils.java new file mode 100644 index 0000000..4683a4e --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/HandlerUtils.java @@ -0,0 +1,60 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.iot.model.ListTargetsForSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.ListTargetsForSecurityProfileResponse; +import software.amazon.awssdk.services.iot.model.SecurityProfileTarget; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; + +public class HandlerUtils { + + static Set listTargetsForSecurityProfile( + IotClient iotClient, + AmazonWebServicesClientProxy proxy, + String securityProfileName) { + + String nextToken = null; + Set result = new HashSet<>(); + do { + ListTargetsForSecurityProfileRequest listRequest = ListTargetsForSecurityProfileRequest.builder() + .securityProfileName(securityProfileName) + .nextToken(nextToken) + .build(); + ListTargetsForSecurityProfileResponse listResponse = proxy.injectCredentialsAndInvokeV2( + listRequest, iotClient::listTargetsForSecurityProfile); + List securityProfileTargets = listResponse.securityProfileTargets(); + securityProfileTargets.forEach(target -> result.add(target.arn())); + nextToken = listResponse.nextToken(); + } while (nextToken != null); + + return result; + } + + static Set listTags( + IotClient iotClient, + AmazonWebServicesClientProxy proxy, + String resourceArn) { + + String nextToken = null; + Set result = new HashSet<>(); + do { + ListTagsForResourceRequest listTagsRequest = ListTagsForResourceRequest.builder() + .resourceArn(resourceArn) + .nextToken(nextToken) + .build(); + ListTagsForResourceResponse listTagsForResourceResponse = proxy.injectCredentialsAndInvokeV2( + listTagsRequest, iotClient::listTagsForResource); + result.addAll(listTagsForResourceResponse.tags()); + nextToken = listTagsForResourceResponse.nextToken(); + } while (nextToken != null); + + return result; + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ListHandler.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ListHandler.java new file mode 100644 index 0000000..82a9e8b --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ListHandler.java @@ -0,0 +1,57 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.List; +import java.util.stream.Collectors; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.ListSecurityProfilesRequest; +import software.amazon.awssdk.services.iot.model.ListSecurityProfilesResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandler extends BaseHandler { + + private final IotClient iotClient; + + public ListHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ListSecurityProfilesRequest listRequest = ListSecurityProfilesRequest.builder() + .nextToken(request.getNextToken()) + .build(); + + ListSecurityProfilesResponse listSecurityProfilesResponse; + try { + listSecurityProfilesResponse = proxy.injectCredentialsAndInvokeV2( + listRequest, iotClient::listSecurityProfiles); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(request.getDesiredResourceState(), e, logger); + } + + List models = listSecurityProfilesResponse.securityProfileIdentifiers().stream() + .map(identifier -> ResourceModel.builder() + .securityProfileName(identifier.name()) + .build()) + .collect(Collectors.toList()); + + logger.log(String.format("Listed %s resources for accountId %s.", + ResourceModel.TYPE_NAME, request.getAwsAccountId())); + + return ProgressEvent.builder() + .resourceModels(models) + .nextToken(listSecurityProfilesResponse.nextToken()) + .status(OperationStatus.SUCCESS) + .build(); + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ReadHandler.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ReadHandler.java new file mode 100644 index 0000000..8a179b4 --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/ReadHandler.java @@ -0,0 +1,109 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.Set; + +import com.google.common.annotations.VisibleForTesting; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandler extends BaseHandler { + + private final IotClient iotClient; + + public ReadHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel model = request.getDesiredResourceState(); + String securityProfileName = model.getSecurityProfileName(); + + DescribeSecurityProfileRequest describeRequest = DescribeSecurityProfileRequest.builder() + .securityProfileName(securityProfileName) + .build(); + DescribeSecurityProfileResponse describeResponse; + try { + describeResponse = proxy.injectCredentialsAndInvokeV2( + describeRequest, iotClient::describeSecurityProfile); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(model, e, logger); + } + + + String securityProfileArn = describeResponse.securityProfileArn(); + logger.log("Called Describe for " + securityProfileArn); + + // DescribeSecurityProfile doesn't provide the attached targets, so we call ListTargetsForSecurityProfile. + Set targetArns = listTargetsForSecurityProfile(proxy, securityProfileName); + logger.log("Listed targets for " + securityProfileArn); + + // DescribeSecurityProfile doesn't provide the tags, so we call ListTagsForResource. + Set iotTags = listTags(proxy, securityProfileArn); + logger.log("Listed tags for " + securityProfileArn); + + ResourceModel resourceModel = buildResourceModel(describeResponse, targetArns, iotTags); + + return ProgressEvent.defaultSuccessHandler(resourceModel); + } + + // This facilitates mocking in the unit tests. + // It would be nicer to instead pass HandlerUtils (which we can mock) + // to the constructor, but the framework requires the constructor to have 0 args. + @VisibleForTesting + Set listTags(AmazonWebServicesClientProxy proxy, + String resourceArn) { + return HandlerUtils.listTags(iotClient, proxy, resourceArn); + } + + @VisibleForTesting + Set listTargetsForSecurityProfile(AmazonWebServicesClientProxy proxy, + String securityProfileName) { + return HandlerUtils.listTargetsForSecurityProfile( + iotClient, proxy, securityProfileName); + } + + ResourceModel buildResourceModel( + DescribeSecurityProfileResponse describeResponse, + Set targetArns, + Set iotTags) { + + ResourceModel.ResourceModelBuilder resourceModelBuilder = ResourceModel.builder() + .securityProfileName(describeResponse.securityProfileName()) + .securityProfileDescription(describeResponse.securityProfileDescription()) + .securityProfileArn(describeResponse.securityProfileArn()) + .targetArns(targetArns) + .tags(Translator.translateTagsFromIotToCfn(iotTags)); + + // For collections, we're using the .has* methods to differentiate between null and empty collections + // from DescribeSecurityProfileResponse. + // SDK converts nulls from Describe API to empty DefaultSdkAutoConstructList/Maps, + // so if we simply translate without the .has* check, nulls will turn into empty collections. + if (describeResponse.hasBehaviors()) { + resourceModelBuilder.behaviors(Translator.translateBehaviorListFromIotToCfn( + describeResponse.behaviors())); + } + if (describeResponse.hasAlertTargets()) { + resourceModelBuilder.alertTargets(Translator.translateAlertTargetMapFromIotToCfn( + describeResponse.alertTargetsAsStrings())); + } + if (describeResponse.hasAdditionalMetricsToRetainV2()) { + resourceModelBuilder.additionalMetricsToRetainV2( + Translator.translateMetricToRetainListFromIotToCfn( + describeResponse.additionalMetricsToRetainV2())); + } + + return resourceModelBuilder.build(); + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Translator.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Translator.java new file mode 100644 index 0000000..bd286f8 --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/Translator.java @@ -0,0 +1,344 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import lombok.NonNull; +import org.apache.commons.lang3.exception.ExceptionUtils; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.InvalidRequestException; +import software.amazon.awssdk.services.iot.model.LimitExceededException; +import software.amazon.awssdk.services.iot.model.ResourceAlreadyExistsException; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; + +public class Translator { + + static Set translateBehaviorSetFromCfnToIot( + Set cfnBehaviors) { + if (cfnBehaviors == null) { + return null; + } + return cfnBehaviors.stream() + .map(Translator::translateBehaviorFromCfnToIot) + .collect(Collectors.toSet()); + } + + static Set translateBehaviorListFromIotToCfn( + @NonNull List iotBehaviors) { + return iotBehaviors.stream() + .map(Translator::translateBehaviorFromIotToCfn) + .collect(Collectors.toSet()); + } + + private static software.amazon.awssdk.services.iot.model.Behavior translateBehaviorFromCfnToIot( + Behavior cfnBehavior) { + if (cfnBehavior == null) { + return null; + } + return software.amazon.awssdk.services.iot.model.Behavior.builder() + .name(cfnBehavior.getName()) + .metric(cfnBehavior.getMetric()) + .metricDimension(translateMetricDimensionFromCfnToIot(cfnBehavior.getMetricDimension())) + .criteria(translateBehaviorCriteriaFromCfnToIot(cfnBehavior.getCriteria())) + .build(); + } + + private static Behavior translateBehaviorFromIotToCfn( + @NonNull software.amazon.awssdk.services.iot.model.Behavior iotBehavior) { + return Behavior.builder() + .name(iotBehavior.name()) + .metric(iotBehavior.metric()) + .metricDimension(translateMetricDimensionFromIotToCfn(iotBehavior.metricDimension())) + .criteria(translateBehaviorCriteriaFromIotToCfn(iotBehavior.criteria())) + .build(); + } + + private static software.amazon.awssdk.services.iot.model.BehaviorCriteria translateBehaviorCriteriaFromCfnToIot( + BehaviorCriteria cfnCriteria) { + if (cfnCriteria == null) { + return null; + } + return software.amazon.awssdk.services.iot.model.BehaviorCriteria.builder() + .comparisonOperator(cfnCriteria.getComparisonOperator()) + .value(translateMetricValueFromCfnToIot(cfnCriteria.getValue())) + .durationSeconds(cfnCriteria.getDurationSeconds()) + .consecutiveDatapointsToAlarm(cfnCriteria.getConsecutiveDatapointsToAlarm()) + .consecutiveDatapointsToClear(cfnCriteria.getConsecutiveDatapointsToClear()) + .statisticalThreshold(translateStatisticalThresholdFromCfnToIot(cfnCriteria.getStatisticalThreshold())) + .build(); + } + + private static BehaviorCriteria translateBehaviorCriteriaFromIotToCfn( + software.amazon.awssdk.services.iot.model.BehaviorCriteria iotCriteria) { + if (iotCriteria == null) { + return null; + } + return BehaviorCriteria.builder() + .comparisonOperator(iotCriteria.comparisonOperatorAsString()) + .value(translateMetricValueFromIotToCfn(iotCriteria.value())) + .durationSeconds(iotCriteria.durationSeconds()) + .consecutiveDatapointsToAlarm(iotCriteria.consecutiveDatapointsToAlarm()) + .consecutiveDatapointsToClear(iotCriteria.consecutiveDatapointsToClear()) + .statisticalThreshold(translateStatisticalThresholdFromIotToCfn(iotCriteria.statisticalThreshold())) + .build(); + } + + static software.amazon.awssdk.services.iot.model.MetricValue translateMetricValueFromCfnToIot( + MetricValue cfnMetricValue) { + if (cfnMetricValue == null) { + return null; + } + // The MetricValue is a String in the CFN model because the json-schema doesn't support Long numbers. + // This is a known issue and some existing services are already using Strings as the workaround. + // For example, AWS::ElasticLoadBalancing::LoadBalancer HealthCheck + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-elb-health-check.html + String countStringFromCfn = cfnMetricValue.getCount(); + Long countLong; + if (countStringFromCfn == null) { + countLong = null; + } else { + try { + countLong = Long.parseLong(countStringFromCfn); + } catch (NumberFormatException e) { + throw new CfnInvalidRequestException("Invalid value for Behavior::MetricValue::Count: " + + countStringFromCfn); + } + } + + return software.amazon.awssdk.services.iot.model.MetricValue.builder() + .count(countLong) + .cidrs(cfnMetricValue.getCidrs()) + .ports(cfnMetricValue.getPorts()) + .build(); + } + + static MetricValue translateMetricValueFromIotToCfn( + software.amazon.awssdk.services.iot.model.MetricValue iotMetricValue) { + if (iotMetricValue == null) { + return null; + } + + MetricValue.MetricValueBuilder metricValueBuilder = MetricValue.builder(); + if (iotMetricValue.count() != null) { + metricValueBuilder.count(iotMetricValue.count().toString()); + } + // For lists, we're using the .has* methods to differentiate between null and empty lists + // from DescribeSecurityProfileResponse. + // SDK converts nulls from Describe API to empty DefaultSdkAutoConstructLists, + // so if we simply translate without the .has* check, nulls will turn into empty lists. + if (iotMetricValue.hasCidrs()) { + metricValueBuilder.cidrs(new HashSet<>(iotMetricValue.cidrs())); + } + if (iotMetricValue.hasPorts()) { + metricValueBuilder.ports(new HashSet<>(iotMetricValue.ports())); + } + + return metricValueBuilder.build(); + } + + private static software.amazon.awssdk.services.iot.model.MetricDimension translateMetricDimensionFromCfnToIot( + MetricDimension cfnMetricDimension) { + if (cfnMetricDimension == null) { + return null; + } + return software.amazon.awssdk.services.iot.model.MetricDimension.builder() + .dimensionName(cfnMetricDimension.getDimensionName()) + .operator(cfnMetricDimension.getOperator()) + .build(); + } + + private static MetricDimension translateMetricDimensionFromIotToCfn( + software.amazon.awssdk.services.iot.model.MetricDimension iotMetricDimension) { + if (iotMetricDimension == null) { + return null; + } + return MetricDimension.builder() + .dimensionName(iotMetricDimension.dimensionName()) + .operator(iotMetricDimension.operatorAsString()) + .build(); + } + + private static software.amazon.awssdk.services.iot.model.StatisticalThreshold translateStatisticalThresholdFromCfnToIot( + StatisticalThreshold cfnThreshold) { + if (cfnThreshold == null) { + return null; + } + return software.amazon.awssdk.services.iot.model.StatisticalThreshold.builder() + .statistic(cfnThreshold.getStatistic()) + .build(); + } + + private static StatisticalThreshold translateStatisticalThresholdFromIotToCfn( + software.amazon.awssdk.services.iot.model.StatisticalThreshold iotThreshold) { + if (iotThreshold == null) { + return null; + } + return StatisticalThreshold.builder() + .statistic(iotThreshold.statistic()) + .build(); + } + + static Map translateAlertTargetMapFromCfnToIot( + Map cfnAlertTargetMap) { + if (cfnAlertTargetMap == null) { + return null; + } + return cfnAlertTargetMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> translateAlertTargetFromCfnToIot(entry.getValue()))); + } + + static Map translateAlertTargetMapFromIotToCfn( + @NonNull Map iotAlertTargetMap) { + + return iotAlertTargetMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> translateAlertTargetFromIotToCfn(entry.getValue()))); + } + + private static software.amazon.awssdk.services.iot.model.AlertTarget translateAlertTargetFromCfnToIot( + AlertTarget cfnAlertTarget) { + if (cfnAlertTarget == null) { + return null; + } + return software.amazon.awssdk.services.iot.model.AlertTarget.builder() + .alertTargetArn(cfnAlertTarget.getAlertTargetArn()) + .roleArn(cfnAlertTarget.getRoleArn()) + .build(); + } + + private static AlertTarget translateAlertTargetFromIotToCfn( + @NonNull software.amazon.awssdk.services.iot.model.AlertTarget iotAlertTarget) { + return AlertTarget.builder() + .alertTargetArn(iotAlertTarget.alertTargetArn()) + .roleArn(iotAlertTarget.roleArn()) + .build(); + } + + static Set translateMetricToRetainSetFromCfnToIot( + Set cfnMetricToRetainSet) { + if (cfnMetricToRetainSet == null) { + return null; + } + return cfnMetricToRetainSet.stream() + .map(Translator::translateMetricToRetainFromCfnToIot) + .collect(Collectors.toSet()); + } + + static Set translateMetricToRetainListFromIotToCfn( + @NonNull List iotMetricToRetainList) { + return iotMetricToRetainList.stream() + .map(Translator::translateMetricToRetainFromIotToCfn) + .collect(Collectors.toSet()); + } + + private static software.amazon.awssdk.services.iot.model.MetricToRetain translateMetricToRetainFromCfnToIot( + MetricToRetain cfnMetricToRetain) { + if (cfnMetricToRetain == null) { + return null; + } + return software.amazon.awssdk.services.iot.model.MetricToRetain.builder() + .metric(cfnMetricToRetain.getMetric()) + .metricDimension(translateMetricDimensionFromCfnToIot(cfnMetricToRetain.getMetricDimension())) + .build(); + } + + private static MetricToRetain translateMetricToRetainFromIotToCfn( + @NonNull software.amazon.awssdk.services.iot.model.MetricToRetain iotMetricToRetain) { + return MetricToRetain.builder() + .metric(iotMetricToRetain.metric()) + .metricDimension(translateMetricDimensionFromIotToCfn(iotMetricToRetain.metricDimension())) + .build(); + } + + static Set translateTagsFromCfnToIot(Map tags) { + if (tags == null) { + return null; + } + return tags.keySet().stream() + .map(key -> Tag.builder() + .key(key) + .value(tags.get(key)) + .build()) + .collect(Collectors.toSet()); + } + + static Set translateTagsFromIotToCfn( + Set tags) { + if (tags == null) { + return null; + } + return tags.stream() + .map(tag -> com.amazonaws.iot.securityprofile.Tag.builder() + .key(tag.key()) + .value(tag.value()) + .build()) + .collect(Collectors.toSet()); + } + + static ProgressEvent translateExceptionToProgressEvent( + ResourceModel model, Exception e, Logger logger) { + + HandlerErrorCode errorCode = translateExceptionToProgressEvent(e, logger); + ProgressEvent progressEvent = + ProgressEvent.builder() + .resourceModel(model) + .status(OperationStatus.FAILED) + .errorCode(errorCode) + .build(); + if (errorCode != HandlerErrorCode.InternalFailure) { + progressEvent.setMessage(e.getMessage()); + } + return progressEvent; + } + + private static HandlerErrorCode translateExceptionToProgressEvent(Exception e, Logger logger) { + + logger.log(String.format("Translating exception \"%s\", stack trace: %s", + e.getMessage(), ExceptionUtils.getStackTrace(e))); + + // We're handling all the exceptions documented in API docs + // https://docs.aws.amazon.com/iot/latest/apireference/API_CreateSecurityProfile.html + // (+similar pages for other APIs) + // For Throttling and InternalFailure, we want CFN to retry, and it will do so based on the error code. + // Reference with Retriable/Terminal in comments for each: https://tinyurl.com/y378qdno + if (e instanceof ResourceAlreadyExistsException) { + // Note regarding idempotency: + // CreateSecurityProfile API allows tags. CFN attaches its own stack level tags with the request. If a + // SecurityProfile is created out of band and then the same request is sent via CFN, the API will throw + // AlreadyExists because the CFN request will contain the stack level tags. + // This behavior satisfies the CreateHandler contract. + return HandlerErrorCode.AlreadyExists; + } else if (e instanceof InvalidRequestException) { + return HandlerErrorCode.InvalidRequest; + } else if (e instanceof LimitExceededException) { + return HandlerErrorCode.ServiceLimitExceeded; + } else if (e instanceof UnauthorizedException) { + return HandlerErrorCode.AccessDenied; + } else if (e instanceof InternalFailureException) { + return HandlerErrorCode.InternalFailure; + } else if (e instanceof ThrottlingException) { + return HandlerErrorCode.Throttling; + } else if (e instanceof ResourceNotFoundException) { + return HandlerErrorCode.NotFound; + } else { + logger.log(String.format("Unexpected exception \"%s\", stack trace: %s", + e.getMessage(), ExceptionUtils.getStackTrace(e))); + // Any other exception at this point is unexpected. + return HandlerErrorCode.InternalFailure; + } + } +} diff --git a/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/UpdateHandler.java b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/UpdateHandler.java new file mode 100644 index 0000000..a3f77df --- /dev/null +++ b/aws-iot-securityprofile/src/main/java/com/amazonaws/iot/securityprofile/UpdateHandler.java @@ -0,0 +1,252 @@ +package com.amazonaws.iot.securityprofile; + + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.RateLimiter; + +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.AttachSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DetachSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.MetricToRetain; +import software.amazon.awssdk.services.iot.model.Tag; +import software.amazon.awssdk.services.iot.model.TagResourceRequest; +import software.amazon.awssdk.services.iot.model.UntagResourceRequest; +import software.amazon.awssdk.services.iot.model.UpdateSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.UpdateSecurityProfileResponse; +import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandler extends BaseHandler { + + private final IotClient iotClient; + + private static final int MAX_CALLS_PER_SECOND_LIMIT = 5; + + public UpdateHandler() { + iotClient = IotClient.builder().build(); + } + + @Override + public ProgressEvent handleRequest( + AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + CallbackContext callbackContext, + Logger logger) { + + ResourceModel desiredModel = request.getDesiredResourceState(); + String desiredArn = desiredModel.getSecurityProfileArn(); + if (!StringUtils.isEmpty(desiredArn)) { + logger.log("Arn cannot be updated, caller setting it to " + desiredArn); + return ProgressEvent.failed(desiredModel, callbackContext, HandlerErrorCode.InvalidRequest, + "Arn cannot be updated."); + } + + String securityProfileArn; + try { + securityProfileArn = updateSecurityProfile(proxy, desiredModel, logger); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(desiredModel, e, logger); + } + + // Security profile targets are managed by separate APIs, not UpdateSecurityProfile. + try { + updateTargetAttachments(proxy, desiredModel, logger); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(desiredModel, e, logger); + } + + // Same for tags. + try { + updateTags(proxy, request, securityProfileArn, logger); + } catch (Exception e) { + return Translator.translateExceptionToProgressEvent(desiredModel, e, logger); + } + + desiredModel.setSecurityProfileArn(securityProfileArn); + return ProgressEvent.defaultSuccessHandler(desiredModel); + } + + /** + * @return The security profile's ARN. + */ + String updateSecurityProfile(AmazonWebServicesClientProxy proxy, + ResourceModel model, + Logger logger) { + + // If the desired template has no behaviors, passing an empty list in the behaviors field + // is not enough. UpdateSecurityProfile needs us to pass a deleteBehaviors=true flag. + boolean deleteBehaviors; + Set behaviorsForRequest; + if (CollectionUtils.isNullOrEmpty(model.getBehaviors())) { + deleteBehaviors = true; + behaviorsForRequest = null; + } else { + deleteBehaviors = false; + behaviorsForRequest = Translator.translateBehaviorSetFromCfnToIot(model.getBehaviors()); + } + + // Same for alertTargets + boolean deleteAlertTargets; + Map alertTargetsForRequest; + if (CollectionUtils.isNullOrEmpty(model.getAlertTargets())) { + deleteAlertTargets = true; + alertTargetsForRequest = null; + } else { + deleteAlertTargets = false; + alertTargetsForRequest = Translator.translateAlertTargetMapFromCfnToIot(model.getAlertTargets()); + } + + // Same for additionalMetricsToRetain + boolean deleteAdditionalMetricsToRetain; + Set additionalMetricsV2ForRequest; + if (CollectionUtils.isNullOrEmpty(model.getAdditionalMetricsToRetainV2())) { + deleteAdditionalMetricsToRetain = true; + additionalMetricsV2ForRequest = null; + } else { + deleteAdditionalMetricsToRetain = false; + additionalMetricsV2ForRequest = Translator.translateMetricToRetainSetFromCfnToIot( + model.getAdditionalMetricsToRetainV2()); + } + + UpdateSecurityProfileRequest updateRequest = UpdateSecurityProfileRequest.builder() + .securityProfileName(model.getSecurityProfileName()) + .securityProfileDescription(model.getSecurityProfileDescription()) + .behaviors(behaviorsForRequest) + .alertTargetsWithStrings(alertTargetsForRequest) + .additionalMetricsToRetainV2(additionalMetricsV2ForRequest) + .deleteBehaviors(deleteBehaviors) + .deleteAlertTargets(deleteAlertTargets) + .deleteAdditionalMetricsToRetain(deleteAdditionalMetricsToRetain) + .build(); + + UpdateSecurityProfileResponse updateResponse = proxy.injectCredentialsAndInvokeV2( + updateRequest, iotClient::updateSecurityProfile); + String arn = updateResponse.securityProfileArn(); + logger.log("Called UpdateSecurityProfile for " + arn); + return arn; + } + + void updateTargetAttachments(AmazonWebServicesClientProxy proxy, + ResourceModel model, + Logger logger) { + + String securityProfileName = model.getSecurityProfileName(); + // Note: we're intentionally getting current attachments by calling ListTargetsForSecurityProfile + // rather than getting the previous state from CFN. This is in order to overwrite out-of-band changes. + // We have the same behavior in all Device Defender UpdateHandlers with regards to out-of-band updates. + Set currentTargets = listTargetsForSecurityProfile(proxy, securityProfileName); + + Set desiredTargets; + if (model.getTargetArns() == null) { + desiredTargets = Collections.emptySet(); + } else { + desiredTargets = model.getTargetArns(); + } + + Set targetsToAttach = desiredTargets.stream() + .filter(target -> !currentTargets.contains(target)) + .collect(Collectors.toSet()); + Set targetsToDetach = currentTargets.stream() + .filter(target -> !desiredTargets.contains(target)) + .collect(Collectors.toSet()); + + // The number of targets can be large, we need to avoid getting throttled. + RateLimiter rateLimiter = RateLimiter.create(MAX_CALLS_PER_SECOND_LIMIT); + + for (String targetArn : targetsToAttach) { + rateLimiter.acquire(); + AttachSecurityProfileRequest attachRequest = AttachSecurityProfileRequest.builder() + .securityProfileName(securityProfileName) + .securityProfileTargetArn(targetArn) + .build(); + proxy.injectCredentialsAndInvokeV2(attachRequest, iotClient::attachSecurityProfile); + logger.log("Attached " + securityProfileName + " to " + targetArn); + } + + for (String targetArn : targetsToDetach) { + rateLimiter.acquire(); + DetachSecurityProfileRequest detachRequest = DetachSecurityProfileRequest.builder() + .securityProfileName(securityProfileName) + .securityProfileTargetArn(targetArn) + .build(); + proxy.injectCredentialsAndInvokeV2(detachRequest, iotClient::detachSecurityProfile); + logger.log("Detached " + securityProfileName + " from " + targetArn); + } + } + + void updateTags(AmazonWebServicesClientProxy proxy, + ResourceHandlerRequest request, + String resourceArn, + Logger logger) { + + // Note: we're intentionally getting currentTags by calling ListTags rather than getting + // the previous state from CFN. This is in order to overwrite out-of-band changes. + // For example, if we used request.getPreviousResourceTags instead of ListTags, if a user added a new tag + // via TagResource and didn't add it to the template, we wouldn't know about it and wouldn't untag it. + // Yet we should, otherwise the resource wouldn't equate the template. + Set currentTags = listTags(proxy, resourceArn); + + // getDesiredResourceTags includes model+stack-level tags, reference: https://tinyurl.com/y55mqrnc + Set nullableDesiredTags = Translator.translateTagsFromCfnToIot(request.getDesiredResourceTags()); + Set desiredTags = nullableDesiredTags == null ? Collections.emptySet() : nullableDesiredTags; + // TODO: uncomment this after we update the service to allow these (only from CFN) + // SystemTags are the default stack-level tags with aws:cloudformation prefix + // desiredTags.addAll(Translator.translateTagsFromCfnToIot(request.getSystemTags())); + + Set desiredTagKeys = desiredTags.stream() + .map(Tag::key) + .collect(Collectors.toSet()); + + Set tagKeysToDetach = currentTags.stream() + .filter(tag -> !desiredTagKeys.contains(tag.key())) + .map(Tag::key) + .collect(Collectors.toSet()); + Set tagsToAttach = desiredTags.stream() + .filter(tag -> !currentTags.contains(tag)) + .collect(Collectors.toSet()); + + if (!tagsToAttach.isEmpty()) { + TagResourceRequest tagResourceRequest = TagResourceRequest.builder() + .resourceArn(resourceArn) + .tags(tagsToAttach) + .build(); + proxy.injectCredentialsAndInvokeV2(tagResourceRequest, iotClient::tagResource); + logger.log("Called TagResource for " + resourceArn); + } + + if (!tagKeysToDetach.isEmpty()) { + UntagResourceRequest untagResourceRequest = UntagResourceRequest.builder() + .resourceArn(resourceArn) + .tagKeys(tagKeysToDetach) + .build(); + proxy.injectCredentialsAndInvokeV2(untagResourceRequest, iotClient::untagResource); + logger.log("Called UntagResource for " + resourceArn); + } + } + + // This facilitates mocking in the unit tests. + // It would be nicer to instead pass HandlerUtils (which we can mock) + // to the constructor, but the framework requires the constructor to have 0 args. + @VisibleForTesting + Set listTags(AmazonWebServicesClientProxy proxy, + String resourceArn) { + return HandlerUtils.listTags(iotClient, proxy, resourceArn); + } + + @VisibleForTesting + Set listTargetsForSecurityProfile(AmazonWebServicesClientProxy proxy, + String securityProfileName) { + return HandlerUtils.listTargetsForSecurityProfile( + iotClient, proxy, securityProfileName); + } +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/CreateHandlerTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/CreateHandlerTest.java new file mode 100644 index 0000000..36e080d --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/CreateHandlerTest.java @@ -0,0 +1,240 @@ +package com.amazonaws.iot.securityprofile; + +import static com.amazonaws.iot.securityprofile.TestConstants.ADDITIONAL_METRICS_CFN; +import static com.amazonaws.iot.securityprofile.TestConstants.ADDITIONAL_METRICS_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.CLIENT_REQUEST_TOKEN; +import static com.amazonaws.iot.securityprofile.TestConstants.LOGICAL_IDENTIFIER; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_ARN; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_NAME; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_CFN_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_STRINGMAP; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARNS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import software.amazon.awssdk.services.iot.model.AttachSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.AttachSecurityProfileResponse; +import software.amazon.awssdk.services.iot.model.CreateSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.CreateSecurityProfileResponse; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.ResourceAlreadyExistsException; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class CreateHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private CreateHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new CreateHandler(); + } + + @Test + public void handleRequest_HappyCase_VerifyRequestResponse() { + + ResourceModel model = buildResourceModel(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier(LOGICAL_IDENTIFIER) + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .desiredResourceTags(TAG_1_STRINGMAP) + .build(); + + CreateSecurityProfileResponse createResponse = CreateSecurityProfileResponse.builder() + .securityProfileArn(SECURITY_PROFILE_ARN) + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + + ArgumentCaptor requestsCaptor = ArgumentCaptor.forClass(IotRequest.class); + + when(proxy.injectCredentialsAndInvokeV2(requestsCaptor.capture(), any())) + .thenReturn(createResponse) + .thenReturn(AttachSecurityProfileResponse.builder().build()) + .thenReturn(AttachSecurityProfileResponse.builder().build()); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + List iotRequests = requestsCaptor.getAllValues(); + CreateSecurityProfileRequest actualCreateRequest = (CreateSecurityProfileRequest) iotRequests.get(0); + CreateSecurityProfileRequest expectedCreateRequest = CreateSecurityProfileRequest.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_IOT) + .tags(TAG_1_IOT) + .build(); + assertThat(actualCreateRequest).isEqualTo(expectedCreateRequest); + + AttachSecurityProfileRequest actualAttachRequest1 = (AttachSecurityProfileRequest) iotRequests.get(1); + assertThat(actualAttachRequest1.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + assertThat(actualAttachRequest1.securityProfileTargetArn()).isIn(TARGET_ARNS); + + AttachSecurityProfileRequest actualAttachRequest2 = (AttachSecurityProfileRequest) iotRequests.get(1); + assertThat(actualAttachRequest2.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + assertThat(actualAttachRequest2.securityProfileTargetArn()).isIn(TARGET_ARNS); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + ResourceModel expectedModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARNS) + .tags(TAG_1_CFN_SET) + .securityProfileArn(SECURITY_PROFILE_ARN) + .build(); + assertThat(response.getResourceModel()).isEqualTo(expectedModel); + } + + @Test + public void handleRequest_CreateThrowsAlreadyExists_VerifyTranslation() { + + ResourceModel model = buildResourceModel(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier(LOGICAL_IDENTIFIER) + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .desiredResourceTags(TAG_1_STRINGMAP) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ResourceAlreadyExistsException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AlreadyExists); + } + + @Test + public void handleRequest_AttachThrowsNotFound_VerifyTranslation() { + + ResourceModel model = buildResourceModel(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier(LOGICAL_IDENTIFIER) + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .desiredResourceTags(TAG_1_STRINGMAP) + .build(); + + CreateSecurityProfileResponse createResponse = CreateSecurityProfileResponse.builder() + .securityProfileArn(SECURITY_PROFILE_ARN) + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(createResponse) + .thenThrow(ResourceNotFoundException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.NotFound); + } + + @Test + public void handleRequest_NoName_GeneratedByHandler() { + + ResourceModel model = ResourceModel.builder() + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARNS) + .tags(TAG_1_CFN_SET) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier("MyResourceName") + .clientRequestToken("MyToken") + .desiredResourceTags(TAG_1_STRINGMAP) + .stackId("arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack-name/" + + "084c0bd1-082b-11eb-afdc-0a2fadfa68a5") + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(CreateSecurityProfileResponse.builder().build()) + .thenReturn(AttachSecurityProfileResponse.builder().build()) + .thenReturn(AttachSecurityProfileResponse.builder().build()); + + handler.handleRequest(proxy, request, null, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass( + IotRequest.class); + verify(proxy, times(3)).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + + CreateSecurityProfileRequest actualCreateRequest = (CreateSecurityProfileRequest) requestCaptor + .getAllValues().get(0); + // Can't easily check the randomly generated name. Just making sure it contains part of + // the logical identifier and the stack name, and some more random characters + assertThat(actualCreateRequest.securityProfileName()).contains("my-stack"); + assertThat(actualCreateRequest.securityProfileName()).contains("MyRes"); + assertThat(actualCreateRequest.securityProfileName().length() > 20).isTrue(); + + AttachSecurityProfileRequest actualAttachRequest = (AttachSecurityProfileRequest) requestCaptor + .getAllValues().get(1); + assertThat(actualCreateRequest.securityProfileName()).isEqualTo(actualAttachRequest.securityProfileName()); + } + + @Test + public void handleRequest_NonEmptyArn_ExpectFailure() { + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARNS) + .tags(TAG_1_CFN_SET) + .securityProfileArn("Arn is read-only") + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .logicalResourceIdentifier(LOGICAL_IDENTIFIER) + .clientRequestToken(CLIENT_REQUEST_TOKEN) + .desiredResourceTags(TAG_1_STRINGMAP) + .build(); + + ProgressEvent response = + handler.handleRequest(proxy, request, null, logger); + + assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED); + assertThat(response.getErrorCode()).isEqualTo(HandlerErrorCode.InvalidRequest); + } + + private ResourceModel buildResourceModel() { + return ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARNS) + .tags(TAG_1_CFN_SET) + .build(); + } + + // TODO: test system tags when the src code is ready +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/DeleteHandlerTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/DeleteHandlerTest.java new file mode 100644 index 0000000..4449508 --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/DeleteHandlerTest.java @@ -0,0 +1,117 @@ +package com.amazonaws.iot.securityprofile; + +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import software.amazon.awssdk.services.iot.model.DeleteSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileResponse; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class DeleteHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private DeleteHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new DeleteHandler(); + } + + @Test + public void handleRequest_HappyCase_VerifyRequest() { + + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(2)).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + + DescribeSecurityProfileRequest describeRequest = + (DescribeSecurityProfileRequest) requestCaptor.getAllValues().get(0); + assertThat(describeRequest.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + + DeleteSecurityProfileRequest deleteRequest = + (DeleteSecurityProfileRequest) requestCaptor.getAllValues().get(1); + assertThat(deleteRequest.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + } + + @Test + public void handleRequest_DescribeThrowsNotFound_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ResourceNotFoundException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.NotFound); + } + + @Test + public void handleRequest_DeleteThrowsException_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(DescribeSecurityProfileResponse.builder().build()) + .thenThrow(InternalFailureException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + } +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/HandlerUtilsTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/HandlerUtilsTest.java new file mode 100644 index 0000000..3e650e6 --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/HandlerUtilsTest.java @@ -0,0 +1,126 @@ +package com.amazonaws.iot.securityprofile; + +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_ARN; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_NAME; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_TARGET_1; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_TARGET_2; +import static com.amazonaws.iot.securityprofile.TestConstants.TAGS_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_2_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARNS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iot.model.LimitExceededException; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceRequest; +import software.amazon.awssdk.services.iot.model.ListTagsForResourceResponse; +import software.amazon.awssdk.services.iot.model.ListTargetsForSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.ListTargetsForSecurityProfileResponse; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; + +public class HandlerUtilsTest { + + private static final String NEXT_TOKEN = "testToken"; + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private IotClient iotClient; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + iotClient = IotClient.builder().build(); + } + + @Test + void listTargetsForSecurityProfile_WithNextToken_VerifyPagination() { + + ListTargetsForSecurityProfileRequest expectedRequest1 = ListTargetsForSecurityProfileRequest.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + ListTargetsForSecurityProfileResponse response1 = ListTargetsForSecurityProfileResponse.builder() + .securityProfileTargets(SECURITY_PROFILE_TARGET_1) + .nextToken(NEXT_TOKEN) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest1), any())) + .thenReturn(response1); + + ListTargetsForSecurityProfileRequest expectedRequest2 = ListTargetsForSecurityProfileRequest.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .nextToken(NEXT_TOKEN) + .build(); + ListTargetsForSecurityProfileResponse response2 = ListTargetsForSecurityProfileResponse.builder() + .securityProfileTargets(SECURITY_PROFILE_TARGET_2) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest2), any())) + .thenReturn(response2); + + Set actualResponse = HandlerUtils.listTargetsForSecurityProfile( + iotClient, proxy, SECURITY_PROFILE_NAME); + assertThat(actualResponse).isEqualTo(TARGET_ARNS); + } + + @Test + void listTargetsForSecurityProfile_ApiThrowsException_BubbleUp() { + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ThrottlingException.builder().build()); + assertThatThrownBy(() -> HandlerUtils.listTargetsForSecurityProfile( + iotClient, proxy, SECURITY_PROFILE_NAME)) + .isInstanceOf(ThrottlingException.class); + } + + @Test + public void listTags_WithNextToken_VerifyPagination() { + + ListTagsForResourceRequest expectedRequest1 = ListTagsForResourceRequest.builder() + .resourceArn(SECURITY_PROFILE_ARN) + .build(); + ListTagsForResourceResponse response1 = ListTagsForResourceResponse.builder() + .tags(TAG_1_IOT) + .nextToken(NEXT_TOKEN) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest1), any())) + .thenReturn(response1); + + ListTagsForResourceRequest expectedRequest2 = ListTagsForResourceRequest.builder() + .resourceArn(SECURITY_PROFILE_ARN) + .nextToken(NEXT_TOKEN) + .build(); + ListTagsForResourceResponse listTagsForResourceResponse2 = ListTagsForResourceResponse.builder() + .tags(TAG_2_IOT) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest2), any())) + .thenReturn(listTagsForResourceResponse2); + + Set actualResponse = + HandlerUtils.listTags(iotClient, proxy, SECURITY_PROFILE_ARN); + assertThat(actualResponse).isEqualTo(TAGS_IOT); + } + + @Test + void listTags_ApiThrowsException_BubbleUp() { + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(LimitExceededException.builder().build()); + assertThatThrownBy(() -> HandlerUtils.listTags( + iotClient, proxy, SECURITY_PROFILE_ARN)) + .isInstanceOf(LimitExceededException.class); + } +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ListHandlerTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ListHandlerTest.java new file mode 100644 index 0000000..f1fc292 --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ListHandlerTest.java @@ -0,0 +1,102 @@ +package com.amazonaws.iot.securityprofile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import software.amazon.awssdk.services.iot.model.ListSecurityProfilesRequest; +import software.amazon.awssdk.services.iot.model.ListSecurityProfilesResponse; +import software.amazon.awssdk.services.iot.model.UnauthorizedException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ListHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + private ListHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new ListHandler(); + } + + @Test + public void handleRequest_HappyCase_VerifyRequestResponse() { + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .nextToken("nextToken1") + .build(); + + ListSecurityProfilesRequest expectedRequest = ListSecurityProfilesRequest.builder() + .nextToken(request.getNextToken()) + .build(); + + software.amazon.awssdk.services.iot.model.SecurityProfileIdentifier identifier1 = + software.amazon.awssdk.services.iot.model.SecurityProfileIdentifier.builder() + .arn("doesn't matter") + .name("profile1") + .build(); + software.amazon.awssdk.services.iot.model.SecurityProfileIdentifier identifier2 = + software.amazon.awssdk.services.iot.model.SecurityProfileIdentifier.builder() + .arn("doesn't matter") + .name("profile2") + .build(); + ListSecurityProfilesResponse listResponse = ListSecurityProfilesResponse.builder() + .securityProfileIdentifiers(identifier1, identifier2) + .nextToken("nextToken2") + .build(); + + when(proxy.injectCredentialsAndInvokeV2(eq(expectedRequest), any())) + .thenReturn(listResponse); + + ProgressEvent response = + handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + assertThat(response.getNextToken()).isEqualTo("nextToken2"); + List expectedModels = Arrays.asList( + ResourceModel.builder().securityProfileName("profile1").build(), + ResourceModel.builder().securityProfileName("profile2").build()); + assertThat(response.getResourceModels()).isEqualTo(expectedModels); + } + + @Test + public void handleRequest_ApiThrowsException_VerifyTranslation() { + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .nextToken("nextToken1") + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(UnauthorizedException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.AccessDenied); + } +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ReadHandlerTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ReadHandlerTest.java new file mode 100644 index 0000000..2d3a5c5 --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/ReadHandlerTest.java @@ -0,0 +1,162 @@ +package com.amazonaws.iot.securityprofile; + +import static com.amazonaws.iot.securityprofile.TestConstants.ADDITIONAL_METRICS_CFN; +import static com.amazonaws.iot.securityprofile.TestConstants.ADDITIONAL_METRICS_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.ALERT_TARGET_MAP_CFN; +import static com.amazonaws.iot.securityprofile.TestConstants.ALERT_TARGET_MAP_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.BEHAVIOR_1_CFN_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.BEHAVIOR_1_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_ARN; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_DESCRIPTION; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_NAME; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_CFN_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_IOT_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARN_1_SET; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DescribeSecurityProfileResponse; +import software.amazon.awssdk.services.iot.model.ThrottlingException; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class ReadHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @Spy + private ReadHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void handleRequest_AllFieldsPopulated_VerifyRequestResponse() { + + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + DescribeSecurityProfileRequest expectedDescribeRequest = DescribeSecurityProfileRequest.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + DescribeSecurityProfileResponse describeResponse = DescribeSecurityProfileResponse.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileArn(SECURITY_PROFILE_ARN) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .behaviors(BEHAVIOR_1_IOT) + .alertTargetsWithStrings(ALERT_TARGET_MAP_IOT) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_IOT) + .build(); + when(proxy.injectCredentialsAndInvokeV2(eq(expectedDescribeRequest), any())) + .thenReturn(describeResponse); + + doReturn(TARGET_ARN_1_SET) + .when(handler) + .listTargetsForSecurityProfile(proxy, SECURITY_PROFILE_NAME); + + doReturn(TAG_1_IOT_SET) + .when(handler) + .listTags(proxy, SECURITY_PROFILE_ARN); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + ResourceModel expectedModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileArn(SECURITY_PROFILE_ARN) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .behaviors(BEHAVIOR_1_CFN_SET) + .alertTargets(ALERT_TARGET_MAP_CFN) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARN_1_SET) + .tags(TAG_1_CFN_SET) + .build(); + assertThat(response.getResourceModel()).isEqualTo(expectedModel); + } + + @Test + public void buildResourceModel_NullCollections_StayNull() { + + Set behaviors = null; + Map alertTargetMap = null; + List additionalMetricsV1 = null; + List additionalMetricsV2 = null; + + DescribeSecurityProfileResponse describeResponse = DescribeSecurityProfileResponse.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileArn(SECURITY_PROFILE_ARN) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .behaviors(behaviors) + .alertTargetsWithStrings(alertTargetMap) + .additionalMetricsToRetain(additionalMetricsV1) + .additionalMetricsToRetainV2(additionalMetricsV2) + .build(); + + ResourceModel actualModel = handler.buildResourceModel(describeResponse, TARGET_ARN_1_SET, TAG_1_IOT_SET); + + ResourceModel expectedModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileArn(SECURITY_PROFILE_ARN) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .behaviors(null) + .alertTargets(null) + .additionalMetricsToRetainV2(null) + .targetArns(TARGET_ARN_1_SET) + .tags(TAG_1_CFN_SET) + .build(); + assertThat(actualModel).isEqualTo(expectedModel); + } + + @Test + public void handleRequest_DescribeThrowsException_VerifyTranslation() { + + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .build(); + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(model) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ThrottlingException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.Throttling); + } +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TestConstants.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TestConstants.java new file mode 100644 index 0000000..ccbd0ba --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TestConstants.java @@ -0,0 +1,117 @@ +package com.amazonaws.iot.securityprofile; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +import software.amazon.awssdk.services.iot.model.ComparisonOperator; + +public class TestConstants { + + static final String SECURITY_PROFILE_NAME = "TestSecurityProfile"; + static final String SECURITY_PROFILE_ARN = + "arn:aws:iot:us-east-1:123456789012:dimension/TestSecurityProfile"; + static final String SECURITY_PROFILE_DESCRIPTION = "TestDescription"; + static final String CLIENT_REQUEST_TOKEN = "TestToken"; + static final String LOGICAL_IDENTIFIER = "LogicalIdentifier"; + static final String BEHAVIOR_NAME = "testBehavior"; + static final MetricDimension DIMENSION_CFN = MetricDimension.builder() + .dimensionName("TestDimension") + .operator("NOT_IN") + .build(); + static final MetricToRetain METRIC_TO_RETAIN_CFN = MetricToRetain.builder() + .metric("aws:num-messages-sent") + .metricDimension(DIMENSION_CFN) + .build(); + static final software.amazon.awssdk.services.iot.model.MetricDimension DIMENSION_IOT = + software.amazon.awssdk.services.iot.model.MetricDimension.builder() + .dimensionName("TestDimension") + .operator("NOT_IN") + .build(); + static final software.amazon.awssdk.services.iot.model.MetricToRetain METRIC_TO_RETAIN_IOT = + software.amazon.awssdk.services.iot.model.MetricToRetain.builder() + .metric("aws:num-messages-sent") + .metricDimension(DIMENSION_IOT) + .build(); + static final Set ADDITIONAL_METRICS_CFN = ImmutableSet.of(METRIC_TO_RETAIN_CFN); + static final List ADDITIONAL_METRICS_IOT = + ImmutableList.of(METRIC_TO_RETAIN_IOT); + static final String TARGET_ARN_1 = "arn:aws:iot:us-west-2:123456789012:all/unregistered-things"; + static final String TARGET_ARN_2 = "arn:aws:iot:us-west-2:123456789012:all/registered-things"; + static final Set TARGET_ARN_1_SET = ImmutableSet.of(TARGET_ARN_1); + static final Set TARGET_ARN_2_SET = ImmutableSet.of(TARGET_ARN_2); + static final Set TARGET_ARNS = ImmutableSet.of(TARGET_ARN_1, TARGET_ARN_2); + static final String TAG_1_KEY = "TagKey1"; + static final List TAG_1_KEY_LIST = ImmutableList.of(TAG_1_KEY); + static final Set TAG_1_CFN_SET = ImmutableSet.of( + Tag.builder() + .key(TAG_1_KEY) + .value("TagValue1") + .build()); + static final Map TAG_1_STRINGMAP = ImmutableMap.of( + TAG_1_KEY, "TagValue1"); + static final Map TAG_2_STRINGMAP = ImmutableMap.of( + "TagKey2", "TagValue2"); + static final software.amazon.awssdk.services.iot.model.Tag TAG_1_IOT = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key(TAG_1_KEY) + .value("TagValue1") + .build(); + static final software.amazon.awssdk.services.iot.model.Tag TAG_2_IOT = + software.amazon.awssdk.services.iot.model.Tag.builder() + .key("TagKey2") + .value("TagValue2") + .build(); + static final Set TAG_1_IOT_SET = ImmutableSet.of(TAG_1_IOT); + static final List TAG_2_IOT_LIST = ImmutableList.of(TAG_2_IOT); + static final Set TAGS_IOT = + ImmutableSet.of(TAG_1_IOT, TAG_2_IOT); + static final software.amazon.awssdk.services.iot.model.SecurityProfileTarget SECURITY_PROFILE_TARGET_1 = + software.amazon.awssdk.services.iot.model.SecurityProfileTarget.builder() + .arn(TARGET_ARN_1) + .build(); + static final software.amazon.awssdk.services.iot.model.SecurityProfileTarget SECURITY_PROFILE_TARGET_2 = + software.amazon.awssdk.services.iot.model.SecurityProfileTarget.builder() + .arn(TARGET_ARN_2) + .build(); + static final software.amazon.awssdk.services.iot.model.BehaviorCriteria CRITERIA_1_IOT = + software.amazon.awssdk.services.iot.model.BehaviorCriteria.builder() + .comparisonOperator(ComparisonOperator.GREATER_THAN) + .build(); + static final software.amazon.awssdk.services.iot.model.Behavior BEHAVIOR_1_IOT = + software.amazon.awssdk.services.iot.model.Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:message-byte-size") + .metricDimension(DIMENSION_IOT) + .criteria(CRITERIA_1_IOT) + .build(); + static final List BEHAVIOR_1_IOT_LIST = + ImmutableList.of(BEHAVIOR_1_IOT); + static final BehaviorCriteria CRITERIA_1_CFN = BehaviorCriteria.builder() + .comparisonOperator("greater-than") + .build(); + static final Behavior BEHAVIOR_1_CFN = Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:message-byte-size") + .metricDimension(DIMENSION_CFN) + .criteria(CRITERIA_1_CFN) + .build(); + static final Set BEHAVIOR_1_CFN_SET = ImmutableSet.of(BEHAVIOR_1_CFN); + static final software.amazon.awssdk.services.iot.model.AlertTarget ALERT_TARGET_IOT = + software.amazon.awssdk.services.iot.model.AlertTarget.builder() + .alertTargetArn("testAlertTargetArn") + .roleArn("testRoleArn") + .build(); + static final AlertTarget ALERT_TARGET_CFN = AlertTarget.builder() + .alertTargetArn("testAlertTargetArn") + .roleArn("testRoleArn") + .build(); + static final Map ALERT_TARGET_MAP_IOT = + ImmutableMap.of("SNS", ALERT_TARGET_IOT); + static final Map ALERT_TARGET_MAP_CFN = + ImmutableMap.of("SNS", ALERT_TARGET_CFN); +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TranslatorTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TranslatorTest.java new file mode 100644 index 0000000..dc74915 --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/TranslatorTest.java @@ -0,0 +1,185 @@ +package com.amazonaws.iot.securityprofile; + +import static com.amazonaws.iot.securityprofile.TestConstants.BEHAVIOR_NAME; +import static com.amazonaws.iot.securityprofile.Translator.translateBehaviorListFromIotToCfn; +import static com.amazonaws.iot.securityprofile.Translator.translateBehaviorSetFromCfnToIot; +import static com.amazonaws.iot.securityprofile.Translator.translateMetricValueFromCfnToIot; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + +import org.junit.jupiter.api.Test; +import software.amazon.cloudformation.exceptions.CfnInvalidRequestException; + +public class TranslatorTest { + + @Test + void translateBehaviors_SeveralLegalBehaviors_VerifyRoundTripTranslation() { + + Behavior scalarMetricBehaviorCfn = buildScalarMetricBehaviorCfn(); + software.amazon.awssdk.services.iot.model.Behavior scalarMetricBehaviorIot = buildScalarMetricBehaviorIot(); + + Behavior sourceIpBehaviorCfn = buildSourceIpBehaviorCfn(); + software.amazon.awssdk.services.iot.model.Behavior sourceIpBehaviorIot = buildSourceIpBehaviorIot(); + + Behavior listeningPortsBehaviorCfn = buildListeningPortsBehaviorCfn(); + software.amazon.awssdk.services.iot.model.Behavior listeningPortsBehaviorIot = + buildListeningPortsBehaviorIot(); + + Behavior statisticalThresholdBehaviorCfn = buildStatisticalThresholdBehaviorCfn(); + software.amazon.awssdk.services.iot.model.Behavior statisticalThresholdBehaviorIot = + buildStatisticalThresholdBehaviorIot(); + + Set cfnBehaviors = ImmutableSet.of( + sourceIpBehaviorCfn, listeningPortsBehaviorCfn, statisticalThresholdBehaviorCfn, + scalarMetricBehaviorCfn); + Set iotBehaviors = ImmutableSet.of( + sourceIpBehaviorIot, listeningPortsBehaviorIot, statisticalThresholdBehaviorIot, + scalarMetricBehaviorIot); + + Set actualIotBehaviors = + translateBehaviorSetFromCfnToIot(cfnBehaviors); + assertThat(actualIotBehaviors).isEqualTo(iotBehaviors); + + Set actualCfnBehaviors = translateBehaviorListFromIotToCfn(new ArrayList<>(iotBehaviors)); + assertThat(actualCfnBehaviors).isEqualTo(cfnBehaviors); + } + + private Behavior buildSourceIpBehaviorCfn() { + BehaviorCriteria behaviorCriteria = BehaviorCriteria.builder() + .comparisonOperator("in-cidr-set") + .value(MetricValue.builder() + .cidrs(ImmutableSet.of( + "192.168.100.14/24", + "192.168.101.14/24", + "192.168.102.14/24")) + .build()) + .consecutiveDatapointsToAlarm(5) + .build(); + return Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:source-ip-address") + .criteria(behaviorCriteria) + .build(); + } + + private software.amazon.awssdk.services.iot.model.Behavior buildSourceIpBehaviorIot() { + software.amazon.awssdk.services.iot.model.BehaviorCriteria behaviorCriteria = + software.amazon.awssdk.services.iot.model.BehaviorCriteria.builder() + .comparisonOperator("in-cidr-set") + .value(software.amazon.awssdk.services.iot.model.MetricValue.builder() + .cidrs(ImmutableSet.of( + "192.168.100.14/24", + "192.168.101.14/24", + "192.168.102.14/24")) + .build()) + .consecutiveDatapointsToAlarm(5) + .build(); + return software.amazon.awssdk.services.iot.model.Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:source-ip-address") + .criteria(behaviorCriteria) + .build(); + } + + private Behavior buildListeningPortsBehaviorCfn() { + BehaviorCriteria behaviorCriteria = BehaviorCriteria.builder() + .comparisonOperator("in-port-set") + .value(MetricValue.builder() + .ports(ImmutableSet.of(40, 443)) + .build()) + .consecutiveDatapointsToClear(5) + .build(); + return Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:listening-tcp-ports") + .criteria(behaviorCriteria) + .build(); + } + + private software.amazon.awssdk.services.iot.model.Behavior buildListeningPortsBehaviorIot() { + software.amazon.awssdk.services.iot.model.BehaviorCriteria behaviorCriteria = + software.amazon.awssdk.services.iot.model.BehaviorCriteria.builder() + .comparisonOperator("in-port-set") + .value(software.amazon.awssdk.services.iot.model.MetricValue.builder() + .ports(ImmutableSet.of(40, 443)) + .build()) + .consecutiveDatapointsToClear(5) + .build(); + return software.amazon.awssdk.services.iot.model.Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:listening-tcp-ports") + .criteria(behaviorCriteria) + .build(); + } + + private Behavior buildStatisticalThresholdBehaviorCfn() { + BehaviorCriteria behaviorCriteria = BehaviorCriteria.builder() + .comparisonOperator("greater-than") + .durationSeconds(300) + .statisticalThreshold(StatisticalThreshold.builder().statistic("p90").build()) + .build(); + return Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:num-authorization-failures") + .criteria(behaviorCriteria) + .build(); + } + + private software.amazon.awssdk.services.iot.model.Behavior buildStatisticalThresholdBehaviorIot() { + software.amazon.awssdk.services.iot.model.BehaviorCriteria behaviorCriteria = + software.amazon.awssdk.services.iot.model.BehaviorCriteria.builder() + .comparisonOperator("greater-than") + .durationSeconds(300) + .statisticalThreshold(software.amazon.awssdk.services.iot.model.StatisticalThreshold + .builder().statistic("p90").build()) + .build(); + return software.amazon.awssdk.services.iot.model.Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:num-authorization-failures") + .criteria(behaviorCriteria) + .build(); + } + + private Behavior buildScalarMetricBehaviorCfn() { + BehaviorCriteria behaviorCriteria = BehaviorCriteria.builder() + .comparisonOperator("less-than") + .durationSeconds(600) + .value(MetricValue + .builder().count("999999999999").build()) + .build(); + return Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:num-messages-sent") + .criteria(behaviorCriteria) + .build(); + } + + private software.amazon.awssdk.services.iot.model.Behavior buildScalarMetricBehaviorIot() { + software.amazon.awssdk.services.iot.model.BehaviorCriteria behaviorCriteria = + software.amazon.awssdk.services.iot.model.BehaviorCriteria.builder() + .comparisonOperator("less-than") + .durationSeconds(600) + .value(software.amazon.awssdk.services.iot.model.MetricValue + .builder().count(999999999999L).build()) + .build(); + return software.amazon.awssdk.services.iot.model.Behavior.builder() + .name(BEHAVIOR_NAME) + .metric("aws:num-messages-sent") + .criteria(behaviorCriteria) + .build(); + } + + @Test + void translateMetricValueFromCfnToIot_ScalarValue_NotANumber() { + MetricValue cfnMetricValue = MetricValue.builder() + .count("123IllegalInput") + .build(); + assertThatThrownBy(() -> translateMetricValueFromCfnToIot(cfnMetricValue)) + .isInstanceOf(CfnInvalidRequestException.class); + } +} diff --git a/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/UpdateHandlerTest.java b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/UpdateHandlerTest.java new file mode 100644 index 0000000..06101cd --- /dev/null +++ b/aws-iot-securityprofile/src/test/java/com/amazonaws/iot/securityprofile/UpdateHandlerTest.java @@ -0,0 +1,383 @@ +package com.amazonaws.iot.securityprofile; + +import static com.amazonaws.iot.securityprofile.TestConstants.ADDITIONAL_METRICS_CFN; +import static com.amazonaws.iot.securityprofile.TestConstants.ADDITIONAL_METRICS_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.ALERT_TARGET_MAP_CFN; +import static com.amazonaws.iot.securityprofile.TestConstants.ALERT_TARGET_MAP_IOT; +import static com.amazonaws.iot.securityprofile.TestConstants.BEHAVIOR_1_CFN_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.BEHAVIOR_1_IOT_LIST; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_ARN; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_DESCRIPTION; +import static com.amazonaws.iot.securityprofile.TestConstants.SECURITY_PROFILE_NAME; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_IOT_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_KEY; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_KEY_LIST; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_1_STRINGMAP; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_2_IOT_LIST; +import static com.amazonaws.iot.securityprofile.TestConstants.TAG_2_STRINGMAP; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARN_1; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARN_1_SET; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARN_2; +import static com.amazonaws.iot.securityprofile.TestConstants.TARGET_ARN_2_SET; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import software.amazon.awssdk.services.iot.model.AttachSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.DetachSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.InternalFailureException; +import software.amazon.awssdk.services.iot.model.InvalidRequestException; +import software.amazon.awssdk.services.iot.model.IotRequest; +import software.amazon.awssdk.services.iot.model.ResourceNotFoundException; +import software.amazon.awssdk.services.iot.model.TagResourceRequest; +import software.amazon.awssdk.services.iot.model.UntagResourceRequest; +import software.amazon.awssdk.services.iot.model.UpdateSecurityProfileRequest; +import software.amazon.awssdk.services.iot.model.UpdateSecurityProfileResponse; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ResourceHandlerRequest; + +public class UpdateHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @Spy + private UpdateHandler handler; + + @BeforeEach + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void handleRequest_AllFieldsUpdated_VerifyRequests() { + + ResourceModel previousModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileArn(SECURITY_PROFILE_ARN) + .build(); + ResourceModel desiredModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .behaviors(BEHAVIOR_1_CFN_SET) + .alertTargets(ALERT_TARGET_MAP_CFN) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARN_2_SET) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceState(desiredModel) + .desiredResourceTags(TAG_2_STRINGMAP) + .build(); + + doReturn(TARGET_ARN_1_SET) + .when(handler) + .listTargetsForSecurityProfile(proxy, SECURITY_PROFILE_NAME); + doReturn(TAG_1_IOT_SET) + .when(handler) + .listTags(proxy, SECURITY_PROFILE_ARN); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenReturn(UpdateSecurityProfileResponse.builder().securityProfileArn(SECURITY_PROFILE_ARN).build()); + + ProgressEvent response + = handler.handleRequest(proxy, request, null, logger); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); + assertThat(response.getResourceModels()).isNull(); + assertThat(response.getMessage()).isNull(); + assertThat(response.getErrorCode()).isNull(); + + desiredModel.setSecurityProfileArn(SECURITY_PROFILE_ARN); + assertThat(response.getResourceModel()).isEqualTo(desiredModel); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(5)).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + List submittedIotRequests = requestCaptor.getAllValues(); + + UpdateSecurityProfileRequest submittedUpdateRequest = + (UpdateSecurityProfileRequest) submittedIotRequests.get(0); + assertThat(submittedUpdateRequest.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + assertThat(submittedUpdateRequest.securityProfileDescription()).isEqualTo(SECURITY_PROFILE_DESCRIPTION); + assertThat(submittedUpdateRequest.behaviors()).isEqualTo(BEHAVIOR_1_IOT_LIST); + assertThat(submittedUpdateRequest.alertTargetsAsStrings()).isEqualTo(ALERT_TARGET_MAP_IOT); + assertThat(submittedUpdateRequest.additionalMetricsToRetainV2()).isEqualTo(ADDITIONAL_METRICS_IOT); + assertThat(submittedUpdateRequest.deleteBehaviors()).isFalse(); + assertThat(submittedUpdateRequest.deleteAlertTargets()).isFalse(); + assertThat(submittedUpdateRequest.deleteAdditionalMetricsToRetain()).isFalse(); + + AttachSecurityProfileRequest submittedAttachRequest = + (AttachSecurityProfileRequest) submittedIotRequests.get(1); + assertThat(submittedAttachRequest.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + assertThat(submittedAttachRequest.securityProfileTargetArn()).isEqualTo(TARGET_ARN_2); + + DetachSecurityProfileRequest submittedDetachRequest = + (DetachSecurityProfileRequest) submittedIotRequests.get(2); + assertThat(submittedDetachRequest.securityProfileName()).isEqualTo(SECURITY_PROFILE_NAME); + assertThat(submittedDetachRequest.securityProfileTargetArn()).isEqualTo(TARGET_ARN_1); + + TagResourceRequest submittedTagRequest = (TagResourceRequest) submittedIotRequests.get(3); + assertThat(submittedTagRequest.tags()).isEqualTo(TAG_2_IOT_LIST); + assertThat(submittedTagRequest.resourceArn()).isEqualTo(SECURITY_PROFILE_ARN); + + UntagResourceRequest submittedUntagRequest = (UntagResourceRequest) submittedIotRequests.get(4); + assertThat(submittedUntagRequest.tagKeys()).isEqualTo(TAG_1_KEY_LIST); + assertThat(submittedUntagRequest.resourceArn()).isEqualTo(SECURITY_PROFILE_ARN); + } + + @Test + public void updateSecurityProfile_NullCollections_VerifyDeleteFlags() { + + UpdateSecurityProfileRequest expectedUpdateRequest = UpdateSecurityProfileRequest.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .deleteBehaviors(true) + .deleteAlertTargets(true) + .deleteAdditionalMetricsToRetain(true) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(eq(expectedUpdateRequest), any())) + .thenReturn(UpdateSecurityProfileResponse.builder().securityProfileArn(SECURITY_PROFILE_ARN).build()); + + ResourceModel modelWithNullCollections = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .targetArns(TARGET_ARN_2_SET) + .build(); + + String actualArn = handler.updateSecurityProfile(proxy, modelWithNullCollections, logger); + assertThat(actualArn).isEqualTo(SECURITY_PROFILE_ARN); + } + + @Test + public void updateSecurityProfile_EmptyCollections_VerifyDeleteFlags() { + + UpdateSecurityProfileRequest expectedUpdateRequest = UpdateSecurityProfileRequest.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .deleteBehaviors(true) + .deleteAlertTargets(true) + .deleteAdditionalMetricsToRetain(true) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(eq(expectedUpdateRequest), any())) + .thenReturn(UpdateSecurityProfileResponse.builder().securityProfileArn(SECURITY_PROFILE_ARN).build()); + + ResourceModel modelWithEmptyCollections = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .targetArns(TARGET_ARN_2_SET) + .behaviors(Collections.emptySet()) + .alertTargets(Collections.emptyMap()) + .additionalMetricsToRetainV2(Collections.emptySet()) + .build(); + + String actualArn = handler.updateSecurityProfile(proxy, modelWithEmptyCollections, logger); + assertThat(actualArn).isEqualTo(SECURITY_PROFILE_ARN); + } + + @Test + public void updateTargetAttachments_3Before3After_2Attach2Detach1Keep() { + + Set previousTargets = ImmutableSet.of("keepTarget", "detachTarget1", "detachTarget2"); + doReturn(previousTargets) + .when(handler) + .listTargetsForSecurityProfile(proxy, SECURITY_PROFILE_NAME); + + Set desiredTargets = ImmutableSet.of("keepTarget", "attachTarget1", "attachTarget2"); + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .targetArns(desiredTargets) + .build(); + + handler.updateTargetAttachments(proxy, model, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(IotRequest.class); + verify(proxy, times(4)).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + + AttachSecurityProfileRequest actualAttachRequest1 = + (AttachSecurityProfileRequest) requestCaptor.getAllValues().get(0); + AttachSecurityProfileRequest actualAttachRequest2 = + (AttachSecurityProfileRequest) requestCaptor.getAllValues().get(1); + Set actualAttachTargets = ImmutableSet.of(actualAttachRequest1.securityProfileTargetArn(), + actualAttachRequest2.securityProfileTargetArn()); + assertThat(actualAttachTargets).containsExactlyInAnyOrder("attachTarget1", "attachTarget2"); + + DetachSecurityProfileRequest actualDetachRequest1 = + (DetachSecurityProfileRequest) requestCaptor.getAllValues().get(2); + DetachSecurityProfileRequest actualDetachRequest2 = + (DetachSecurityProfileRequest) requestCaptor.getAllValues().get(3); + Set actualDetachTargets = ImmutableSet.of(actualDetachRequest1.securityProfileTargetArn(), + actualDetachRequest2.securityProfileTargetArn()); + assertThat(actualDetachTargets).containsExactlyInAnyOrder("detachTarget1", "detachTarget2"); + } + + @Test + public void updateTags_SameKeyDifferentValue_OnlyTagCall() { + + Map desiredTagsCfn = ImmutableMap.of(TAG_1_KEY, "NewValue"); + List desiredTagsIot = ImmutableList.of( + software.amazon.awssdk.services.iot.model.Tag.builder() + .key(TAG_1_KEY) + .value("NewValue") + .build()); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(ResourceModel.builder().build()) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceTags(desiredTagsCfn) + .build(); + + doReturn(TAG_1_IOT_SET) + .when(handler) + .listTags(proxy, SECURITY_PROFILE_ARN); + + handler.updateTags(proxy, request, SECURITY_PROFILE_ARN, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(TagResourceRequest.class); + verify(proxy).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + TagResourceRequest submittedTagRequest = requestCaptor.getValue(); + assertThat(submittedTagRequest.tags()).isEqualTo(desiredTagsIot); + } + + @Test + public void updateTags_NoDesiredTags_OnlyUntagCall() { + + Map desiredTags = Collections.emptyMap(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(ResourceModel.builder().build()) + .previousResourceTags(ImmutableMap.of("doesn't", "matter")) + .desiredResourceTags(desiredTags) + .build(); + + doReturn(TAG_1_IOT_SET) + .when(handler) + .listTags(proxy, SECURITY_PROFILE_ARN); + + handler.updateTags(proxy, request, SECURITY_PROFILE_ARN, logger); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UntagResourceRequest.class); + verify(proxy).injectCredentialsAndInvokeV2(requestCaptor.capture(), any()); + UntagResourceRequest submittedUntagRequest = requestCaptor.getValue(); + assertThat(submittedUntagRequest.tagKeys()).isEqualTo(TAG_1_KEY_LIST); + } + + @Test + public void updateSecurityProfile_ResourceNotFound_VerifyTranslation() { + + ResourceModel desiredModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileDescription(SECURITY_PROFILE_DESCRIPTION) + .additionalMetricsToRetainV2(ADDITIONAL_METRICS_CFN) + .targetArns(TARGET_ARN_2_SET) + .build(); + ResourceModel previousModel = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .securityProfileArn(SECURITY_PROFILE_ARN) + .build(); + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(previousModel) + .desiredResourceState(desiredModel) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(ResourceNotFoundException.builder().build()); + + ProgressEvent progressEvent = + handler.handleRequest(proxy, request, null, logger); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.NotFound); + } + + @Test + public void updateTags_ApiThrowsException_BubbleUp() { + + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .previousResourceState(ResourceModel.builder().build()) + .previousResourceTags(TAG_1_STRINGMAP) + .desiredResourceTags(TAG_1_STRINGMAP) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(InvalidRequestException.builder().build()); + + assertThatThrownBy(() -> + handler.updateTags(proxy, request, SECURITY_PROFILE_ARN, logger)) + .isInstanceOf(InvalidRequestException.class); + } + + @Test + public void updateTargetAttachments_AttachThrowsException_BubbleUp() { + + Set previousTargets = ImmutableSet.of("keepTarget", "detachTarget1", "detachTarget2"); + doReturn(previousTargets) + .when(handler) + .listTargetsForSecurityProfile(proxy, SECURITY_PROFILE_NAME); + + Set desiredTargets = ImmutableSet.of("keepTarget", "attachTarget1", "attachTarget2"); + ResourceModel model = ResourceModel.builder() + .securityProfileName(SECURITY_PROFILE_NAME) + .targetArns(desiredTargets) + .build(); + + when(proxy.injectCredentialsAndInvokeV2(any(), any())) + .thenThrow(InternalFailureException.builder().build()); + + assertThatThrownBy(() -> + handler.updateTargetAttachments(proxy, model, logger)) + .isInstanceOf(InternalFailureException.class); + } + + @Test + void handleRequest_DesiredArnIsPopulatedAndSame_ReturnFailed() { + + ResourceModel desiredModel = ResourceModel.builder() + .securityProfileArn(SECURITY_PROFILE_ARN) + .build(); + ResourceHandlerRequest request = ResourceHandlerRequest.builder() + .desiredResourceState(desiredModel) + .build(); + + ProgressEvent result = + handler.handleRequest(proxy, request, null, logger); + + assertThat(result).isEqualTo(ProgressEvent.failed( + desiredModel, null, HandlerErrorCode.InvalidRequest, "Arn cannot be updated.")); + } + + // TODO: test system tags when the src code is ready +} diff --git a/aws-iot-securityprofile/template.yml b/aws-iot-securityprofile/template.yml new file mode 100644 index 0000000..dc98dc8 --- /dev/null +++ b/aws-iot-securityprofile/template.yml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWS::IoT::SecurityProfile resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.iot.securityprofile.HandlerWrapper::handleRequest + Runtime: java8 + CodeUri: ./target/aws-iot-securityprofile-handler-1.0-SNAPSHOT.jar + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: com.amazonaws.iot.securityprofile.HandlerWrapper::testEntrypoint + Runtime: java8 + CodeUri: ./target/aws-iot-securityprofile-handler-1.0-SNAPSHOT.jar