diff --git a/prime-router/docs/api/validate.yml b/prime-router/docs/api/validate.yml new file mode 100644 index 00000000000..6ddbc10ca1e --- /dev/null +++ b/prime-router/docs/api/validate.yml @@ -0,0 +1,67 @@ +openapi: 3.0.2 +info: + title: Prime ReportStream + description: A router of public health data from multiple senders and receivers + contact: + name: USDS at Centers for Disease Control and Prevention + url: https://reportstream.cdc.gov + email: reportstream@cdc.gov + version: 0.2.0-oas3 +tags: + - name: validate + description: ReportStream validation API + +paths: + # The validation endpoints are public endpoints for validating payloads of various formats. + /validate: + post: + tags: + - validate + summary: Validate a message using client information + parameters: + - in: header + name: client + description: The client.sender to validate against. If client is not known, use `schema` and `format` instead. + schema: + type: string + example: simple_report.default + - in: query + name: schema + description: > + The schema path to validate the message against. Must be use with `format`. + This parameter is incompatible with `client`. + schema: + type: string + example: hl7/hcintegrations-covid-19 + - in: query + name: format + description: > + The format of the message. must be used with `schema`. + This parameter is incompatible with `client`. + schema: + type: string + enum: + - CSV + - HL7 + - HL7_BATCH + example: HL7 + requestBody: + description: The message to validate + required: true + content: + text/csv: + schema: + type: string + example: + header1, header2 + + value1, value2 + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: 'https://raw.githubusercontent.com/CDCgov/prime-reportstream/master/prime-router/docs/api/reports.yml#/components/schemas/Report' + '400': + description: Bad Request \ No newline at end of file diff --git a/prime-router/docs/release-notes.md b/prime-router/docs/release-notes.md index 17d58bf40ba..861ba19ffe4 100644 --- a/prime-router/docs/release-notes.md +++ b/prime-router/docs/release-notes.md @@ -6,6 +6,17 @@ - The ReportStream API is documented here: [Hub OpenApi Spec](./api) - More detailed changelog for individual releases: [Recent releases](https://github.com/CDCgov/prime-reportstream/releases) +## January 31, 2023 + +### Make validation endpoint public + +This release removes authentication/authorization from the `/api/validate` endpoint. + +Previously, the `client` parameter had to be set in the header. This is still an option, but now, the client +can pass `schema` and `format` as query parameters and a schema matching those values will be used to +validate the message. Additional information can be found in the API documentation. + + ## November 29, 2022 ### Change to the api/token endpoint diff --git a/prime-router/src/main/kotlin/azure/RequestFunction.kt b/prime-router/src/main/kotlin/azure/RequestFunction.kt index f7ecd71e153..b75bda6549f 100644 --- a/prime-router/src/main/kotlin/azure/RequestFunction.kt +++ b/prime-router/src/main/kotlin/azure/RequestFunction.kt @@ -3,12 +3,16 @@ package gov.cdc.prime.router.azure import com.google.common.net.HttpHeaders import com.microsoft.azure.functions.HttpRequestMessage import gov.cdc.prime.router.ActionLogger +import gov.cdc.prime.router.CustomerStatus import gov.cdc.prime.router.DEFAULT_SEPARATOR import gov.cdc.prime.router.HasSchema import gov.cdc.prime.router.InvalidParamMessage import gov.cdc.prime.router.ROUTE_TO_SEPARATOR import gov.cdc.prime.router.Schema import gov.cdc.prime.router.Sender +import gov.cdc.prime.router.TopicSender +import java.lang.IllegalArgumentException +import java.security.InvalidParameterException const val CLIENT_PARAMETER = "client" const val PAYLOAD_NAME_PARAMETER = "payloadname" @@ -17,6 +21,8 @@ const val DEFAULT_PARAMETER = "default" const val ROUTE_TO_PARAMETER = "routeTo" const val ALLOW_DUPLICATES_PARAMETER = "allowDuplicate" const val TOPIC_PARAMETER = "topic" +const val SCHEMA_PARAMETER = "schema" +const val FORMAT_PARAMETER = "format" /** * Base class for ReportFunction and ValidateFunction @@ -31,7 +37,7 @@ abstract class RequestFunction( val content: String = "", val defaults: Map = emptyMap(), val routeTo: List = emptyList(), - val sender: Sender, + val sender: Sender?, val topic: String = "covid-19" ) @@ -70,14 +76,26 @@ abstract class RequestFunction( routeTo.filter { workflowEngine.settings.findReceiver(it) == null } .forEach { actionLogs.error(InvalidParamMessage("Invalid receiver name: $it")) } + var sender: Sender? = null val clientName = extractClient(request) if (clientName.isBlank()) { - actionLogs.error(InvalidParamMessage("Expected a '$CLIENT_PARAMETER' query parameter")) - } - - val sender = workflowEngine.settings.findSender(clientName) - if (sender == null) { - actionLogs.error(InvalidParamMessage("'$CLIENT_PARAMETER:$clientName': unknown sender")) + // Find schema via SCHEMA_PARAMETER parameter + try { + sender = getDummySender( + request.queryParameters.getOrDefault(SCHEMA_PARAMETER, null), + request.queryParameters.getOrDefault(FORMAT_PARAMETER, null) + ) + } catch (e: InvalidParameterException) { + actionLogs.error( + InvalidParamMessage(e.message.toString()) + ) + } + } else { + // Find schema via CLIENT_PARAMETER parameter + sender = workflowEngine.settings.findSender(clientName) + if (sender == null) { + actionLogs.error(InvalidParamMessage("'$CLIENT_PARAMETER:$clientName': unknown sender")) + } } // verify schema if the sender is a topic sender @@ -86,11 +104,12 @@ abstract class RequestFunction( schema = workflowEngine.metadata.findSchema(sender.schemaName) if (schema == null) { actionLogs.error( - InvalidParamMessage("'$CLIENT_PARAMETER:$clientName': unknown schema '${sender.schemaName}'") + InvalidParamMessage("unknown schema '${sender.schemaName}'") ) } } + // validate content type val contentType = request.headers.getOrDefault(HttpHeaders.CONTENT_TYPE.lowercase(), "") if (contentType.isBlank()) { actionLogs.error(InvalidParamMessage("Missing ${HttpHeaders.CONTENT_TYPE} header")) @@ -151,4 +170,37 @@ abstract class RequestFunction( topic ) } + + /** + * Return [TopicSender] for a given schema if that schema exists. This lets us wrap the data needed by + * processRequest without making changes to the method + * @param schemaName the name or path of the schema + * @param format the message format that the schema supports + * @return TopicSender if schema exists, null otherwise + * @throws InvalidParameterException if [schemaName] or [formatName] is not valid + */ + @Throws(InvalidParameterException::class) + internal fun getDummySender(schemaName: String?, formatName: String?): TopicSender { + val errMsgPrefix = "No client found in header so expected valid " + + "'$SCHEMA_PARAMETER' and '$FORMAT_PARAMETER' query parameters but found error: " + if (schemaName != null && formatName != null) { + val schema = workflowEngine.metadata.findSchema(schemaName) + ?: throw InvalidParameterException("$errMsgPrefix The schema with name '$schemaName' does not exist") + val format = try { + Sender.Format.valueOf(formatName) + } catch (e: IllegalArgumentException) { + throw InvalidParameterException("$errMsgPrefix The format '$formatName' is not supported") + } + return TopicSender( + "ValidationSender", + "Internal", + format, + CustomerStatus.TESTING, + schemaName, + schema.topic + ) + } else { + throw InvalidParameterException("$errMsgPrefix 'SchemaName' and 'format' parameters must not be null") + } + } } \ No newline at end of file diff --git a/prime-router/src/main/kotlin/azure/ValidateFunction.kt b/prime-router/src/main/kotlin/azure/ValidateFunction.kt index 50387011343..41f5f63347b 100644 --- a/prime-router/src/main/kotlin/azure/ValidateFunction.kt +++ b/prime-router/src/main/kotlin/azure/ValidateFunction.kt @@ -23,10 +23,8 @@ import gov.cdc.prime.router.history.DetailedActionLog import gov.cdc.prime.router.history.DetailedReport import gov.cdc.prime.router.history.DetailedSubmissionHistory import gov.cdc.prime.router.history.ReportStreamFilterResultForResponse -import gov.cdc.prime.router.tokens.AuthenticatedClaims -import gov.cdc.prime.router.tokens.authenticationFailure -import gov.cdc.prime.router.tokens.authorizationFailure import org.apache.logging.log4j.kotlin.Logging +import java.security.InvalidParameterException import java.time.OffsetDateTime /** @@ -44,30 +42,30 @@ class ValidateFunction( */ @FunctionName("validate") @StorageAccount("AzureWebJobsStorage") - fun run( + fun validate( @HttpTrigger( name = "validate", methods = [HttpMethod.POST], authLevel = AuthorizationLevel.ANONYMOUS ) request: HttpRequestMessage ): HttpResponseMessage { - val senderName = extractClient(request) - if (senderName.isBlank()) { - return HttpUtilities.bad(request, "Expected a '$CLIENT_PARAMETER' query parameter") - } return try { - val claims = AuthenticatedClaims.authenticate(request) - ?: return HttpUtilities.unauthorizedResponse(request, authenticationFailure) - - // Sender should eventually be obtained directly from who is authenticated - val sender = workflowEngine.settings.findSender(senderName) - ?: return HttpUtilities.bad(request, "'$CLIENT_PARAMETER:$senderName': unknown sender") - - if (!claims.authorizedForSendOrReceive(sender, request)) { - return HttpUtilities.unauthorizedResponse(request, authorizationFailure) + val sender: Sender? + val senderName = extractClient(request) + sender = if (senderName.isNotBlank()) { + workflowEngine.settings.findSender(senderName) + ?: return HttpUtilities.bad(request, "'$CLIENT_PARAMETER:$senderName': unknown sender") + } else { + try { + getDummySender( + request.queryParameters.getOrDefault(SCHEMA_PARAMETER, null), + request.queryParameters.getOrDefault(FORMAT_PARAMETER, null) + ) + } catch (e: InvalidParameterException) { + return HttpUtilities.bad(request, e.message.toString()) + } } actionHistory.trackActionParams(request) - processRequest(request, sender) } catch (ex: Exception) { if (ex.message != null) { @@ -80,7 +78,7 @@ class ValidateFunction( } /** - * Handles an incoming validation request after it has been authenticated via the /validate endpoint. + * Handles an incoming validation request from the /validate endpoint. * @param request The incoming request * @param sender The sender record, pulled from the database based on sender name on the request * @return Returns an HttpResponseMessage indicating the result of the operation and any resulting information @@ -96,9 +94,8 @@ class ValidateFunction( try { val validatedRequest = validateRequest(request) - // if the override parameter is populated, use that, otherwise use the sender value - val allowDuplicates = if - (!allowDuplicatesParam.isNullOrEmpty()) allowDuplicatesParam == "true" + // if the override parameter is populated, use that, otherwise use the sender value. Default to false. + val allowDuplicates = if (!allowDuplicatesParam.isNullOrEmpty()) allowDuplicatesParam == "true" else { sender.allowDuplicates } @@ -111,7 +108,6 @@ class ValidateFunction( validatedRequest.routeTo, allowDuplicates, ) - // return OK status, report validation was successful HttpStatus.OK } catch (e: ActionError) { diff --git a/prime-router/src/test/kotlin/azure/ValidateFunctionTests.kt b/prime-router/src/test/kotlin/azure/ValidateFunctionTests.kt index 1cf20af4bb6..b795639c4d1 100644 --- a/prime-router/src/test/kotlin/azure/ValidateFunctionTests.kt +++ b/prime-router/src/test/kotlin/azure/ValidateFunctionTests.kt @@ -11,9 +11,9 @@ import gov.cdc.prime.router.Receiver import gov.cdc.prime.router.Sender import gov.cdc.prime.router.SettingsProvider import gov.cdc.prime.router.Topic +import gov.cdc.prime.router.TopicSender import gov.cdc.prime.router.azure.db.enums.TaskAction import gov.cdc.prime.router.tokens.AuthenticatedClaims -import gov.cdc.prime.router.tokens.AuthenticationType import gov.cdc.prime.router.unittest.UnitTestUtils import io.mockk.clearAllMocks import io.mockk.every @@ -26,6 +26,9 @@ import org.jooq.tools.jdbc.MockDataProvider import org.jooq.tools.jdbc.MockResult import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.security.InvalidParameterException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class ValidateFunctionTests { val dataProvider = MockDataProvider { emptyArray() } @@ -118,99 +121,75 @@ class ValidateFunctionTests { @Test fun `test validate endpoint with missing client`() { val (validateFunc, req) = setupForDotNotationTests() - val jwt = mapOf("scope" to "simple_report.default.report", "sub" to "c@rlos.com") - val claims = AuthenticatedClaims(jwt, AuthenticationType.Server2Server) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns claims req.httpHeaders += mapOf( "content-length" to "4" ) // Invoke the waters function run - validateFunc.run(req) + validateFunc.validate(req) // processFunction should never be called verify(exactly = 0) { validateFunc.processRequest(any(), any()) } } @Test - fun `test validate endpoint with server2server auth - basic happy path`() { - val (reportFunc, req) = setupForDotNotationTests() - val jwt = mapOf("scope" to "simple_report.default.report", "sub" to "c@rlos.com") - val claims = AuthenticatedClaims(jwt, AuthenticationType.Server2Server) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns claims + fun `test validate endpoint with schemaName and format`() { + val (validateFunc, req) = setupForDotNotationTests() req.httpHeaders += mapOf( - "client" to "simple_report", "content-length" to "4" ) - // Invoke the waters function run - reportFunc.run(req) - // processFunction should be called - verify(exactly = 1) { reportFunc.processRequest(any(), any()) } - } - - @Test - fun `test validate endpoint with server2server auth - claim does not match`() { - val (reportFunc, req) = setupForDotNotationTests() - val jwt = mapOf("scope" to "bogus_org.default.report", "sub" to "c@rlos.com") - val claims = AuthenticatedClaims(jwt, AuthenticationType.Server2Server) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns claims - req.httpHeaders += mapOf( - "client" to "simple_report", - "content-length" to "4" + req.parameters += mapOf( + "schema" to "one", + "format" to "CSV" ) // Invoke the waters function run - reportFunc.run(req) + validateFunc.validate(req) // processFunction should never be called - verify(exactly = 0) { reportFunc.processRequest(any(), any()) } + verify(exactly = 1) { validateFunc.processRequest(any(), any()) } } - /** - * Test that header of the form client:simple_report.default works with the auth code. - */ @Test - fun `test validate endpoint with okta dot-notation client header - basic happy path`() { + fun `test validate endpoint with schemaName but missing format`() { val (validateFunc, req) = setupForDotNotationTests() - val jwt = mapOf("organization" to listOf("DHSender_simple_report"), "sub" to "c@rlos.com") - val claims = AuthenticatedClaims(jwt, AuthenticationType.Okta) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns claims - // This is the most common way our customers use the client string req.httpHeaders += mapOf( - "client" to "simple_report", "content-length" to "4" ) + req.parameters += mapOf( + "schema" to "one" + ) // Invoke the waters function run - validateFunc.run(req) - // processFunction should be called - verify(exactly = 1) { validateFunc.processRequest(any(), any()) } + validateFunc.validate(req) + // processFunction should never be called + verify(exactly = 0) { validateFunc.processRequest(any(), any()) } } @Test - fun `test validate endpoint with okta dot-notation client header - full dotted name`() { + fun `test validate endpoint with format but missing schemaName`() { val (validateFunc, req) = setupForDotNotationTests() - val jwt = mapOf("organization" to listOf("DHSender_simple_report"), "sub" to "c@rlos.com") - val claims = AuthenticatedClaims(jwt, AuthenticationType.Okta) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns claims - // Now try it with a full client name req.httpHeaders += mapOf( - "client" to "simple_report.default", "content-length" to "4" ) - validateFunc.run(req) - verify(exactly = 1) { validateFunc.processRequest(any(), any()) } + req.parameters += mapOf( + "format" to "CSV" + ) + // Invoke the waters function run + validateFunc.validate(req) + // processFunction should never be called + verify(exactly = 0) { validateFunc.processRequest(any(), any()) } } @Test - fun `test validate endpoint with okta dot-notation client header - dotted but not default`() { + fun `test validate endpoint with schemaName but schema not found`() { val (validateFunc, req) = setupForDotNotationTests() - val jwt = mapOf("organization" to listOf("DHSender_simple_report"), "sub" to "c@rlos.com") - val claims = AuthenticatedClaims(jwt, AuthenticationType.Okta) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns claims - // Now try it with a full client name but not with "default" - // The point of these tests is that the call to the auth code only contains the org prefix 'simple_report' req.httpHeaders += mapOf( - "client" to "simple_report.foobar", "content-length" to "4" ) - validateFunc.run(req) - verify(exactly = 1) { validateFunc.processRequest(any(), any()) } + req.parameters += mapOf( + "schema" to "does-not-exist", + "format" to "CSV" + ) + // Invoke the waters function run + validateFunc.validate(req) + // processFunction should never be called + verify(exactly = 0) { validateFunc.processRequest(any(), any()) } } // TODO: Will need to copy this test for Full ELR senders once receiving full ELR is implemented (see #5051) @@ -232,17 +211,13 @@ class ValidateFunctionTests { every { validateFunc.processRequest(any(), any()) } returns resp every { engine.settings.findSender("Test Sender") } returns sender - val testClaims = AuthenticatedClaims.generateTestClaims(null) - mockkObject(AuthenticatedClaims.Companion) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns testClaims - req.httpHeaders += mapOf( "client" to "Test Sender", "content-length" to "4" ) // Invoke function run - validateFunc.run(req) + validateFunc.validate(req) // processFunction should be called verify(exactly = 1) { validateFunc.processRequest(any(), any()) } @@ -271,7 +246,7 @@ class ValidateFunctionTests { ) // Invoke function run - val res = validateFunc.run(req) + val res = validateFunc.validate(req) // verify assert(res.statusCode == 400) @@ -294,17 +269,13 @@ class ValidateFunctionTests { every { validateFunc.processRequest(any(), any()) } returns resp every { engine.settings.findSender("Test Sender") } returns null - val testClaims = AuthenticatedClaims.generateTestClaims(null) - mockkObject(AuthenticatedClaims.Companion) - every { AuthenticatedClaims.Companion.authenticate(any()) } returns testClaims - req.httpHeaders += mapOf( "client" to "Test Sender", "content-length" to "4" ) // Invoke function run - val res = validateFunc.run(req) + val res = validateFunc.validate(req) // verify assert(res.statusCode == 400) @@ -344,4 +315,59 @@ class ValidateFunctionTests { verify(exactly = 2) { engine.isDuplicateItem(any()) } assert(resp.status.equals(HttpStatus.BAD_REQUEST)) } + + @Test + fun `test RequestFunction getDummySender`() { + // setup steps + val metadata = UnitTestUtils.simpleMetadata + val settings = FileSettings().loadOrganizations(oneOrganization) + + val engine = makeEngine(metadata, settings) + val actionHistory = spyk(ActionHistory(TaskAction.receive)) + val validateFunc = spyk(ValidateFunction(engine, actionHistory)) + + // act + val expectedSender = TopicSender( + "ValidationSender", + "Internal", + Sender.Format.CSV, + CustomerStatus.TESTING, + "One", + Topic.TEST + ) + val sender = validateFunc.getDummySender("One", "CSV") + + // assert + assertEquals(expectedSender.name, sender.name) + assertEquals(expectedSender.organizationName, sender.organizationName) + assertEquals(expectedSender.format, sender.format) + assertEquals(expectedSender.customerStatus, sender.customerStatus) + assertEquals(expectedSender.schemaName, sender.schemaName) + assertEquals(expectedSender.topic, sender.topic) + } + + @Test + fun `test RequestFunction getDummySender bad params`() { + // setup steps + val metadata = UnitTestUtils.simpleMetadata + val settings = FileSettings().loadOrganizations(oneOrganization) + + val engine = makeEngine(metadata, settings) + val actionHistory = spyk(ActionHistory(TaskAction.receive)) + val validateFunc = spyk(ValidateFunction(engine, actionHistory)) + + // act + assertFailsWith { + validateFunc.getDummySender(null, "CSV") + } + assertFailsWith { + validateFunc.getDummySender("One", null) + } + assertFailsWith { + validateFunc.getDummySender("DoesNotExist", "CSV") + } + assertFailsWith { + validateFunc.getDummySender("One", "DoesNotExist") + } + } } \ No newline at end of file