Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add Notifications SNS model #50

Merged
merged 6 commits into from
Aug 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 " }
}
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)
}
}