Skip to content

Commit

Permalink
Add Notifications SNS model (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuali925 authored Aug 9, 2021
1 parent 28e688b commit b18a489
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ object NotificationConstants {
const val TAGS_TAG = "tags"
const val URL_TAG = "url"
const val HEADER_PARAMS_TAG = "header_params"
const val TOPIC_ARN_FIELD = "topic_arn"
const val ROLE_ARN_FIELD = "role_arn"
const val HOST_TAG = "host"
const val PORT_TAG = "port"
const val METHOD_TAG = "method"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ enum class ConfigType(val tag: String) {
return tag
}
},
SNS("sns") {
override fun toString(): String {
return tag
}
},
SMTP_ACCOUNT("smtp_account") {
override fun toString(): String {
return tag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ data class EventStatus(
ConfigType.WEBHOOK -> requireNotNull(deliveryStatus)
ConfigType.SLACK -> requireNotNull(deliveryStatus)
ConfigType.EMAIL -> require(emailRecipientStatus.isNotEmpty())
ConfigType.SNS -> requireNotNull(deliveryStatus)
ConfigType.NONE -> log.info("Some config field not recognized")
else -> {
log.info("non-allowed config type for Status")
Expand Down
112 changes: 112 additions & 0 deletions src/main/kotlin/org/opensearch/commons/notifications/model/SNS.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/
package org.opensearch.commons.notifications.model

import org.opensearch.common.io.stream.StreamInput
import org.opensearch.common.io.stream.StreamOutput
import org.opensearch.common.io.stream.Writeable
import org.opensearch.common.xcontent.ToXContent
import org.opensearch.common.xcontent.XContentBuilder
import org.opensearch.common.xcontent.XContentParser
import org.opensearch.common.xcontent.XContentParserUtils
import org.opensearch.commons.notifications.NotificationConstants.ROLE_ARN_FIELD
import org.opensearch.commons.notifications.NotificationConstants.TOPIC_ARN_FIELD
import org.opensearch.commons.utils.fieldIfNotNull
import org.opensearch.commons.utils.logger
import org.opensearch.commons.utils.validateIAMRoleArn
import java.io.IOException
import java.util.regex.Pattern

/**
* SNS notification data model
*/
data class SNS(val topicARN: String, val roleARN: String?) : BaseConfigData {

init {
require(SNS_ARN_REGEX.matcher(topicARN).find()) { "Invalid AWS SNS topic ARN: $topicARN" }
if (roleARN != null) {
validateIAMRoleArn(roleARN)
}
}

override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder {
return builder.startObject()
.field(TOPIC_ARN_FIELD, topicARN)
.fieldIfNotNull(ROLE_ARN_FIELD, roleARN)
.endObject()
}

/**
* Constructor used in transport action communication.
* @param input StreamInput stream to deserialize data from.
*/
constructor(input: StreamInput) : this(
topicARN = input.readString(),
roleARN = input.readOptionalString()
)

@Throws(IOException::class)
override fun writeTo(out: StreamOutput) {
out.writeString(topicARN)
out.writeOptionalString(roleARN)
}

companion object {
private val log by logger(SNS::class.java)

private val SNS_ARN_REGEX =
Pattern.compile("^arn:aws(-[^:]+)?:sns:([a-zA-Z0-9-]+):([0-9]{12}):([a-zA-Z0-9-_]+)$")

/**
* reader to create instance of class from writable.
*/
val reader = Writeable.Reader { SNS(it) }

/**
* Parser to parse xContent
*/
val xParser = XParser { parse(it) }

@JvmStatic
@Throws(IOException::class)
fun parse(xcp: XContentParser): SNS {
var topicARN: String? = null
var roleARN: String? = null

XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp)
while (xcp.nextToken() != XContentParser.Token.END_OBJECT) {
val fieldName = xcp.currentName()
xcp.nextToken()
when (fieldName) {
TOPIC_ARN_FIELD -> topicARN = xcp.textOrNull()
ROLE_ARN_FIELD -> roleARN = xcp.textOrNull()
else -> {
xcp.skipChildren()
log.info("Unexpected field: $fieldName, while parsing SNS destination")
}
}
}
topicARN ?: throw IllegalArgumentException("$TOPIC_ARN_FIELD field absent")
return SNS(topicARN, roleARN)
}

@JvmStatic
@Throws(IOException::class)
fun readFrom(sin: StreamInput): SNS? {
return if (sin.readBoolean()) {
SNS(
topicARN = sin.readString(),
roleARN = sin.readOptionalString()
)
} else null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.opensearch.commons.notifications.model.Chime
import org.opensearch.commons.notifications.model.ConfigType
import org.opensearch.commons.notifications.model.Email
import org.opensearch.commons.notifications.model.EmailGroup
import org.opensearch.commons.notifications.model.SNS
import org.opensearch.commons.notifications.model.Slack
import org.opensearch.commons.notifications.model.SmtpAccount
import org.opensearch.commons.notifications.model.Webhook
Expand All @@ -53,6 +54,7 @@ internal object ConfigDataProperties {
Pair(ConfigType.CHIME, ConfigProperty(Chime.reader, Chime.xParser)),
Pair(ConfigType.WEBHOOK, ConfigProperty(Webhook.reader, Webhook.xParser)),
Pair(ConfigType.EMAIL, ConfigProperty(Email.reader, Email.xParser)),
Pair(ConfigType.SNS, ConfigProperty(SNS.reader, SNS.xParser)),
Pair(ConfigType.EMAIL_GROUP, ConfigProperty(EmailGroup.reader, EmailGroup.xParser)),
Pair(ConfigType.SMTP_ACCOUNT, ConfigProperty(SmtpAccount.reader, SmtpAccount.xParser))
)
Expand All @@ -78,6 +80,7 @@ internal object ConfigDataProperties {
ConfigType.EMAIL_GROUP -> configData is EmailGroup
ConfigType.SMTP_ACCOUNT -> configData is SmtpAccount
ConfigType.CHIME -> configData is Chime
ConfigType.SNS -> configData is SNS
ConfigType.NONE -> true
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
package org.opensearch.commons.utils

import java.net.URL
import java.util.regex.Pattern

// Valid ID characters = (All Base64 chars + "_-") to support UUID format and Base64 encoded IDs
private val VALID_ID_CHARS: Set<Char> = (('a'..'z') + ('A'..'Z') + ('0'..'9') + '+' + '/' + '_' + '-').toSet()
Expand Down Expand Up @@ -69,3 +70,8 @@ fun isValidEmail(email: String): Boolean {
fun isValidId(idString: String): Boolean {
return idString.isNotBlank() && idString.all { VALID_ID_CHARS.contains(it) }
}

fun validateIAMRoleArn(roleARN: String) {
val roleArnRegex = Pattern.compile("^arn:aws(-[^:]+)?:iam::([0-9]{12}):([a-zA-Z_0-9+=,.@\\-_/]+)$")
require(roleArnRegex.matcher(roleARN).find()) { "Invalid AWS role ARN: $roleARN " }
}
105 changes: 105 additions & 0 deletions src/test/kotlin/org/opensearch/commons/notifications/model/SNSTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

package org.opensearch.commons.notifications.model

import com.fasterxml.jackson.core.JsonParseException
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.opensearch.commons.utils.createObjectFromJsonString
import org.opensearch.commons.utils.getJsonString
import org.opensearch.commons.utils.recreateObject

internal class SNSTests {

@Test
fun `SNS should throw exception if empty topic`() {
assertThrows(IllegalArgumentException::class.java) {
SNS("", null)
}
val jsonString = "{\"topic_arn\":\"\"}"
assertThrows(IllegalArgumentException::class.java) {
createObjectFromJsonString(jsonString) { SNS.parse(it) }
}
}

@Test
fun `SNS should throw exception if invalid topic ARN`() {
assertThrows(IllegalArgumentException::class.java) {
SNS("arn:aws:es:us-east-1:012345678989:test", null)
}
val jsonString = "{\"topic_arn\":\"arn:aws:es:us-east-1:012345678989:test\"}"
assertThrows(IllegalArgumentException::class.java) {
createObjectFromJsonString(jsonString) { SNS.parse(it) }
}
}

@Test
fun `SNS should throw exception if invalid role ARN`() {
assertThrows(IllegalArgumentException::class.java) {
SNS("arn:aws:sns:us-east-1:012345678912:topic-test", "arn:aws:iam:us-east-1:0123456789:role-test")
}
val jsonString =
"{\"topic_arn\":\"arn:aws:sns:us-east-1:012345678912:topic-test\",\"role_arn\":\"arn:aws:iam:us-east-1:0123456789:role-test\"}"
assertThrows(IllegalArgumentException::class.java) {
createObjectFromJsonString(jsonString) { SNS.parse(it) }
}
}

@Test
fun `SNS serialize and deserialize transport object should be equal`() {
val sampleSNS = SNS("arn:aws:sns:us-east-1:012345678912:topic-test", "arn:aws:iam::012345678912:role/iam-test")
val recreatedObject = recreateObject(sampleSNS) { SNS(it) }
Assertions.assertEquals(sampleSNS, recreatedObject)
}

@Test
fun `SNS serialize and deserialize using json object should be equal`() {
val sampleSNS = SNS("arn:aws:sns:us-east-1:012345678912:topic-test", "arn:aws:iam::012345678912:role/iam-test")
val jsonString = getJsonString(sampleSNS)
val recreatedObject = createObjectFromJsonString(jsonString) { SNS.parse(it) }
Assertions.assertEquals(sampleSNS, recreatedObject)
}

@Test
fun `SNS should deserialize json object using parser`() {
val sampleSNS = SNS("arn:aws:sns:us-east-1:012345678912:topic-test", "arn:aws:iam::012345678912:role/iam-test")
val jsonString = "{\"topic_arn\":\"${sampleSNS.topicARN}\",\"role_arn\":\"${sampleSNS.roleARN}\"}"
val recreatedObject = createObjectFromJsonString(jsonString) { SNS.parse(it) }
Assertions.assertEquals(sampleSNS, recreatedObject)
}

@Test
fun `SNS should throw exception when invalid json object is passed`() {
val jsonString = "sample message"
assertThrows(JsonParseException::class.java) {
createObjectFromJsonString(jsonString) { SNS.parse(it) }
}
}

@Test
fun `SNS should throw exception when arn is replace with arn2 in json object`() {
val sampleSNS = SNS("arn:aws:sns:us-east-1:012345678912:topic-test", "arn:aws:iam::012345678912:role/iam-test")
val jsonString = "{\"topic_arn2\":\"${sampleSNS.topicARN}\",\"role_arn\":\"${sampleSNS.roleARN}\"}"
assertThrows(IllegalArgumentException::class.java) {
createObjectFromJsonString(jsonString) { SNS.parse(it) }
}
}

@Test
fun `SNS should safely ignore extra field in json object`() {
val sampleSNS = SNS("arn:aws:sns:us-east-1:012345678912:topic-test", null)
val jsonString = "{\"topic_arn\":\"${sampleSNS.topicARN}\", \"another\":\"field\"}"
val recreatedObject = createObjectFromJsonString(jsonString) { SNS.parse(it) }
Assertions.assertEquals(sampleSNS, recreatedObject)
}
}

0 comments on commit b18a489

Please sign in to comment.