From 7aa25fbedad6a8a9cacca94a611da7f91273c283 Mon Sep 17 00:00:00 2001 From: Arek Burdach Date: Tue, 17 Dec 2024 11:14:32 +0100 Subject: [PATCH] [NU-1828] OpenAPI enricher: ability to configure common secret for any security scheme (#7346) --- .../functional/OpenAPIServiceSpec.scala | 6 +- .../openapi/OpenAPIComponentProvider.scala | 2 +- .../openapi/OpenAPIServicesConfig.scala | 82 +++++++++ .../nussknacker/openapi/OpenAPIsConfig.scala | 50 ------ .../parser/ParseToSwaggerService.scala | 3 +- .../openapi/parser/SecuritiesParser.scala | 88 +++++----- .../multiple-schemes-for-single-operation.yml | 36 ++++ .../resources/swagger/service-security.yml | 8 +- .../nussknacker/openapi/BaseOpenAPITest.scala | 6 +- .../openapi/OpenAPIServicesConfigTest.scala | 73 ++++++++ .../openapi/OpenAPIsConfigTest.scala | 26 --- .../nussknacker/openapi/SecurityTest.scala | 163 +++++++++++++----- .../openapi/parser/SwaggerParserTest.scala | 2 +- docs/Changelog.md | 1 + docs/integration/OpenAPI.md | 19 +- 15 files changed, 380 insertions(+), 185 deletions(-) create mode 100644 components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfig.scala delete mode 100644 components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIsConfig.scala create mode 100644 components/openapi/src/test/resources/swagger/multiple-schemes-for-single-operation.yml create mode 100644 components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfigTest.scala delete mode 100644 components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIsConfigTest.scala diff --git a/components/openapi/src/it/scala/pl/touk/nussknacker/openapi/functional/OpenAPIServiceSpec.scala b/components/openapi/src/it/scala/pl/touk/nussknacker/openapi/functional/OpenAPIServiceSpec.scala index cbc7cbe770b..2d9b6631609 100644 --- a/components/openapi/src/it/scala/pl/touk/nussknacker/openapi/functional/OpenAPIServiceSpec.scala +++ b/components/openapi/src/it/scala/pl/touk/nussknacker/openapi/functional/OpenAPIServiceSpec.scala @@ -17,7 +17,7 @@ import pl.touk.nussknacker.engine.util.service.EagerServiceWithStaticParametersA import pl.touk.nussknacker.http.backend.FixedAsyncHttpClientBackendProvider import pl.touk.nussknacker.openapi.enrichers.{SwaggerEnricherCreator, SwaggerEnrichers} import pl.touk.nussknacker.openapi.parser.SwaggerParser -import pl.touk.nussknacker.openapi.{ApiKeyConfig, OpenAPIServicesConfig} +import pl.touk.nussknacker.openapi.{ApiKeySecret, OpenAPIServicesConfig, SecurityConfig, SecuritySchemeName} import pl.touk.nussknacker.test.PatientScalaFutures import java.net.URL @@ -44,10 +44,10 @@ class OpenAPIServiceSpec val client = new DefaultAsyncHttpClient() try { new StubService().withCustomerService { port => - val securities = Map("apikey" -> ApiKeyConfig("TODO")) + val secretBySchemeName = Map(SecuritySchemeName("apikey") -> ApiKeySecret("TODO")) val config = OpenAPIServicesConfig( new URL("http://foo"), - security = Some(securities), + security = secretBySchemeName, rootUrl = Some(new URL(s"http://localhost:$port")) ) val services = SwaggerParser.parse(definition, config).collect { case Valid(service) => diff --git a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIComponentProvider.scala b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIComponentProvider.scala index 86c3de9f4d5..1d84f8314af 100644 --- a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIComponentProvider.scala +++ b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIComponentProvider.scala @@ -10,7 +10,7 @@ import pl.touk.nussknacker.engine.api.CirceUtil import pl.touk.nussknacker.engine.api.component.{ComponentDefinition, ComponentProvider, NussknackerVersion} import pl.touk.nussknacker.engine.api.process.ProcessObjectDependencies import pl.touk.nussknacker.engine.util.config.ConfigEnrichments._ -import pl.touk.nussknacker.openapi.OpenAPIsConfig._ +import pl.touk.nussknacker.openapi.OpenAPIServicesConfig._ import pl.touk.nussknacker.openapi.discovery.SwaggerOpenApiDefinitionDiscovery import pl.touk.nussknacker.openapi.enrichers.{SwaggerEnricherCreator, SwaggerEnrichers} import pl.touk.nussknacker.openapi.parser.ServiceParseError diff --git a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfig.scala b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfig.scala new file mode 100644 index 00000000000..9f88df8152f --- /dev/null +++ b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfig.scala @@ -0,0 +1,82 @@ +package pl.touk.nussknacker.openapi + +import com.typesafe.config.Config +import io.swagger.v3.oas.models.PathItem.HttpMethod +import net.ceedubs.ficus.readers.{ArbitraryTypeReader, ValueReader} +import pl.touk.nussknacker.http.backend.{DefaultHttpClientConfig, HttpClientConfig} +import sttp.model.StatusCode + +import java.net.URL +import scala.util.matching.Regex + +final case class OpenAPIServicesConfig( + url: URL, + // by default we allow only GET, as enrichers should be idempotent and not change data + allowedMethods: List[String] = List(HttpMethod.GET.name()), + codesToInterpretAsEmpty: List[Int] = List(StatusCode.NotFound.code), + namePattern: Regex = ".*".r, + rootUrl: Option[URL] = None, + // For backward compatibility it is called security. We should probably rename it and bundle together with secret + private val security: Map[SecuritySchemeName, Secret] = Map.empty, + private val secret: Option[Secret] = None, + httpClientConfig: HttpClientConfig = DefaultHttpClientConfig() +) { + def securityConfig: SecurityConfig = + new SecurityConfig(secretBySchemeName = security, commonSecretForAnyScheme = secret) +} + +final class SecurityConfig( + secretBySchemeName: Map[SecuritySchemeName, Secret], + commonSecretForAnyScheme: Option[Secret] +) { + + def secret(schemeName: SecuritySchemeName): Option[Secret] = + secretBySchemeName.get(schemeName) orElse commonSecretForAnyScheme + +} + +object SecurityConfig { + def empty: SecurityConfig = new SecurityConfig(Map.empty, None) +} + +final case class SecuritySchemeName(value: String) + +sealed trait Secret + +final case class ApiKeySecret(apiKeyValue: String) extends Secret + +object OpenAPIServicesConfig { + + import net.ceedubs.ficus.Ficus._ + import pl.touk.nussknacker.engine.util.config.ConfigEnrichments._ + import HttpClientConfig._ + + implicit val securitySchemeNameVR: ValueReader[SecuritySchemeName] = + ValueReader[String].map(SecuritySchemeName(_)) + + implicit val regexReader: ValueReader[Regex] = (config: Config, path: String) => new Regex(config.getString(path)) + + implicit val apiKeyVR: ValueReader[ApiKeySecret] = ValueReader.relative { conf => + ApiKeySecret( + apiKeyValue = conf.as[String]("apiKeyValue") + ) + } + + implicit val secretVR: ValueReader[Secret] = ValueReader.relative { conf => + conf.as[String]("type") match { + case "apiKey" => conf.rootAs[ApiKeySecret] + case typ => throw new Exception(s"Not supported swagger security type '$typ' in the configuration") + } + } + + implicit val secretBySchemeNameVR: ValueReader[Map[SecuritySchemeName, Secret]] = + ValueReader[Map[String, Secret]].map { secretBySchemeName => + secretBySchemeName.map { case (schemeNameString, secret) => + SecuritySchemeName(schemeNameString) -> secret + } + } + + implicit val openAPIServicesConfigVR: ValueReader[OpenAPIServicesConfig] = + ArbitraryTypeReader.arbitraryTypeValueReader[OpenAPIServicesConfig] + +} diff --git a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIsConfig.scala b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIsConfig.scala deleted file mode 100644 index 82d3cb553f1..00000000000 --- a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/OpenAPIsConfig.scala +++ /dev/null @@ -1,50 +0,0 @@ -package pl.touk.nussknacker.openapi - -import com.typesafe.config.Config -import io.swagger.v3.oas.models.PathItem.HttpMethod -import net.ceedubs.ficus.readers.{ArbitraryTypeReader, ValueReader} -import pl.touk.nussknacker.http.backend.{DefaultHttpClientConfig, HttpClientConfig} -import sttp.model.StatusCode - -import java.net.URL -import scala.util.matching.Regex - -final case class OpenAPIServicesConfig( - url: URL, - // by default we allow only GET, as enrichers should be idempotent and not change data - allowedMethods: List[String] = List(HttpMethod.GET.name()), - codesToInterpretAsEmpty: List[Int] = List(StatusCode.NotFound.code), - namePattern: Regex = ".*".r, - rootUrl: Option[URL] = None, - security: Option[Map[String, OpenAPISecurityConfig]] = None, - httpClientConfig: HttpClientConfig = DefaultHttpClientConfig() -) - -sealed trait OpenAPISecurityConfig - -final case class ApiKeyConfig(apiKeyValue: String) extends OpenAPISecurityConfig - -object OpenAPIsConfig { - - import net.ceedubs.ficus.Ficus._ - import pl.touk.nussknacker.engine.util.config.ConfigEnrichments._ - - implicit val openAPIServicesConfigVR: ValueReader[OpenAPIServicesConfig] = - ArbitraryTypeReader.arbitraryTypeValueReader[OpenAPIServicesConfig] - - implicit val regexReader: ValueReader[Regex] = (config: Config, path: String) => new Regex(config.getString(path)) - - implicit val openAPISecurityConfigVR: ValueReader[OpenAPISecurityConfig] = ValueReader.relative(conf => { - conf.as[String]("type") match { - case "apiKey" => conf.rootAs[ApiKeyConfig] - case typ => throw new Exception(s"Not supported swagger security type '$typ' in the configuration") - } - }) - - implicit val apiKeyConfigVR: ValueReader[ApiKeyConfig] = ValueReader.relative(conf => { - ApiKeyConfig( - apiKeyValue = conf.as[String]("apiKeyValue") - ) - }) - -} diff --git a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/ParseToSwaggerService.scala b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/ParseToSwaggerService.scala index 546f764f4c1..040083c82f7 100644 --- a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/ParseToSwaggerService.scala +++ b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/ParseToSwaggerService.scala @@ -102,8 +102,7 @@ private[parser] class ParseToSwaggerService(openapi: OpenAPI, openAPIsConfig: Op Option(operation.getSecurity).orElse(Option(openapi.getSecurity)).map(_.asScala.toList).getOrElse(Nil) val securitySchemes = Option(openapi.getComponents).flatMap(c => Option(c.getSecuritySchemes)).map(_.asScala.toMap) - val securities = openAPIsConfig.security.getOrElse(Map.empty) - SecuritiesParser.parseSwaggerSecurities(securityRequirements, securitySchemes, securities) + SecuritiesParser.parseOperationSecurities(securityRequirements, securitySchemes, openAPIsConfig.securityConfig) } private def prepareParameters(operation: Operation): ValidationResult[List[SwaggerParameter]] = { diff --git a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/SecuritiesParser.scala b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/SecuritiesParser.scala index 148ddca2fd5..7a0e2a52699 100644 --- a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/SecuritiesParser.scala +++ b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/parser/SecuritiesParser.scala @@ -7,11 +7,13 @@ import io.swagger.v3.oas.models.security.{SecurityRequirement, SecurityScheme} import pl.touk.nussknacker.engine.api.util.ReflectUtils import pl.touk.nussknacker.openapi.parser.ParseToSwaggerService.ValidationResult import pl.touk.nussknacker.openapi.{ - ApiKeyConfig, ApiKeyInCookie, ApiKeyInHeader, ApiKeyInQuery, - OpenAPISecurityConfig, + ApiKeySecret, + Secret, + SecurityConfig, + SecuritySchemeName, SwaggerSecurity } @@ -21,24 +23,24 @@ private[parser] object SecuritiesParser extends LazyLogging { import cats.syntax.apply._ - def parseSwaggerSecurities( - securityRequirements: List[SecurityRequirement], - securitySchemes: Option[Map[String, SecurityScheme]], - securitiesConfigs: Map[String, OpenAPISecurityConfig] + def parseOperationSecurities( + securityRequirementsDefinition: List[SecurityRequirement], + securitySchemesDefinition: Option[Map[String, SecurityScheme]], + securityConfig: SecurityConfig ): ValidationResult[List[SwaggerSecurity]] = - securityRequirements match { + securityRequirementsDefinition match { case Nil => Nil.validNel case _ => - securitySchemes match { + securitySchemesDefinition match { case None => "There is no security scheme definition in the openAPI definition".invalidNel case Some(securitySchemes) => { // finds the first security requirement that can be met by the config - securityRequirements.view + securityRequirementsDefinition.view .map { securityRequirement => matchSecuritiesForRequiredSchemes( securityRequirement.asScala.keys.toList, securitySchemes, - securitiesConfigs + securityConfig ) } .foldLeft("No security requirement can be met because:".invalidNel[List[SwaggerSecurity]])(_.findValid(_)) @@ -48,55 +50,59 @@ private[parser] object SecuritiesParser extends LazyLogging { } } - def matchSecuritiesForRequiredSchemes( + private def matchSecuritiesForRequiredSchemes( requiredSchemesNames: List[String], securitySchemes: Map[String, SecurityScheme], - securitiesConfigs: Map[String, OpenAPISecurityConfig] + securitiesConfig: SecurityConfig ): ValidationResult[List[SwaggerSecurity]] = - requiredSchemesNames - .map { implicit schemeName: String => - { - val securityScheme: ValidationResult[SecurityScheme] = Validated.fromOption( - securitySchemes.get(schemeName), - NonEmptyList.of(s"""there is no security scheme definition for scheme name "$schemeName"""") - ) - val securityConfig: ValidationResult[OpenAPISecurityConfig] = Validated.fromOption( - securitiesConfigs.get(schemeName), - NonEmptyList.of(s"""there is no security config for scheme name "$schemeName"""") - ) + requiredSchemesNames.map { schemeName => + { + val validatedSecurityScheme: ValidationResult[SecurityScheme] = Validated.fromOption( + securitySchemes.get(schemeName), + NonEmptyList.of(s"""there is no security scheme definition for scheme name "$schemeName"""") + ) + val validatedSecuritySecretConfigured: ValidationResult[Secret] = Validated.fromOption( + securitiesConfig.secret(SecuritySchemeName(schemeName)), + NonEmptyList.of(s"""there is no security secret configured for scheme name "$schemeName"""") + ) - (securityScheme, securityConfig).tupled.andThen(t => getSecurityFromSchemeAndConfig(t._1, t._2)) - } + (validatedSecurityScheme, validatedSecuritySecretConfigured) + .mapN { case (securityScheme, configuredSecret) => + getSecurityFromSchemeAndSecret(securityScheme, configuredSecret) + } + .andThen(identity) } - .foldLeft[ValidationResult[List[SwaggerSecurity]]](Nil.validNel)(_.combine(_)) + }.sequence - def getSecurityFromSchemeAndConfig(securityScheme: SecurityScheme, securityConfig: OpenAPISecurityConfig)( - implicit schemeName: String - ): ValidationResult[List[SwaggerSecurity]] = { + private def getSecurityFromSchemeAndSecret( + securityScheme: SecurityScheme, + secret: Secret + ): ValidationResult[SwaggerSecurity] = { import SecurityScheme.Type._ - (securityScheme.getType, securityConfig) match { - case (APIKEY, apiKeyConfig: ApiKeyConfig) => - getApiKeySecurity(securityScheme, apiKeyConfig) + (securityScheme.getType, secret) match { + case (APIKEY, apiKeySecret: ApiKeySecret) => + getApiKeySecurity(securityScheme, apiKeySecret).validNel case (otherType: SecurityScheme.Type, _) => { - val securityConfigClassName = ReflectUtils.simpleNameWithoutSuffix(securityConfig.getClass) - s"Security type $otherType is not supported yet or ($otherType, $securityConfigClassName) is a mismatch security scheme type and security config pair".invalidNel + val secretClassName = ReflectUtils.simpleNameWithoutSuffix(secret.getClass) + s"Security type $otherType is not supported yet or ($otherType, $secretClassName) is a mismatch security scheme type and security config pair".invalidNel } } } - def getApiKeySecurity(securityScheme: SecurityScheme, apiKeyConfig: ApiKeyConfig)( - implicit schemeName: String - ): ValidationResult[List[SwaggerSecurity]] = { + private def getApiKeySecurity( + securityScheme: SecurityScheme, + apiKeySecret: ApiKeySecret + ): SwaggerSecurity = { val name = securityScheme.getName - val key = apiKeyConfig.apiKeyValue + val key = apiKeySecret.apiKeyValue import SecurityScheme.In._ securityScheme.getIn match { case QUERY => - (ApiKeyInQuery(name, key) :: Nil).validNel + ApiKeyInQuery(name, key) case HEADER => - (ApiKeyInHeader(name, key) :: Nil).validNel + ApiKeyInHeader(name, key) case COOKIE => - (ApiKeyInCookie(name, key) :: Nil).validNel + ApiKeyInCookie(name, key) } } diff --git a/components/openapi/src/test/resources/swagger/multiple-schemes-for-single-operation.yml b/components/openapi/src/test/resources/swagger/multiple-schemes-for-single-operation.yml new file mode 100644 index 00000000000..50828f46a18 --- /dev/null +++ b/components/openapi/src/test/resources/swagger/multiple-schemes-for-single-operation.yml @@ -0,0 +1,36 @@ +openapi: "3.1.0" +info: + title: Simple API overview + version: 2.0.0 +servers: + - url: http://dummy.io +paths: + /: + get: + security: + - headerConfig: [] + - queryConfig: [] + - cookieConfig: [] + responses: + '200': + description: "-" + content: + application/json: + schema: + type: object + operationId: root +components: + schemas: {} + securitySchemes: + headerConfig: + type: apiKey + name: keyHeader + in: header + queryConfig: + type: apiKey + name: keyParam + in: query + cookieConfig: + type: apiKey + name: keyCookie + in: cookie diff --git a/components/openapi/src/test/resources/swagger/service-security.yml b/components/openapi/src/test/resources/swagger/service-security.yml index b2641b3c917..db96a36d113 100644 --- a/components/openapi/src/test/resources/swagger/service-security.yml +++ b/components/openapi/src/test/resources/swagger/service-security.yml @@ -16,7 +16,7 @@ paths: application/json: schema: type: object - operationId: header + operationId: headerOperationId /queryPath: get: security: @@ -28,7 +28,7 @@ paths: application/json: schema: type: object - operationId: query + operationId: queryOperationId /cookiePath: get: security: @@ -40,7 +40,7 @@ paths: application/json: schema: type: object - operationId: cookie + operationId: cookieOperationId components: schemas: {} securitySchemes: @@ -55,4 +55,4 @@ components: cookieConfig: type: apiKey name: keyCookie - in: cookie \ No newline at end of file + in: cookie diff --git a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/BaseOpenAPITest.scala b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/BaseOpenAPITest.scala index 88c76368c57..e4392b42363 100644 --- a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/BaseOpenAPITest.scala +++ b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/BaseOpenAPITest.scala @@ -4,12 +4,12 @@ import cats.data.Validated import cats.data.Validated.{Invalid, Valid} import org.apache.commons.io.IOUtils import pl.touk.nussknacker.engine.api.process.ComponentUseCase -import pl.touk.nussknacker.engine.api.{Context, ContextId, JobData, MetaData, ProcessVersion, StreamMetaData} +import pl.touk.nussknacker.engine.api._ import pl.touk.nussknacker.engine.util.runtimecontext.TestEngineRuntimeContext import pl.touk.nussknacker.engine.util.service.EagerServiceWithStaticParametersAndReturnType import pl.touk.nussknacker.openapi.enrichers.{SwaggerEnricherCreator, SwaggerEnrichers} import pl.touk.nussknacker.openapi.parser.{ServiceParseError, SwaggerParser} -import sttp.client3.testing.SttpBackendStub +import sttp.client3.SttpBackend import java.net.URL import java.nio.charset.StandardCharsets @@ -47,7 +47,7 @@ trait BaseOpenAPITest { protected def parseToEnrichers( resource: String, - backend: SttpBackendStub[Future, Any], + backend: SttpBackend[Future, Any], config: OpenAPIServicesConfig = baseConfig ): Map[ServiceName, EagerServiceWithStaticParametersAndReturnType] = { val services = parseServicesFromResourceUnsafe(resource, config) diff --git a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfigTest.scala b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfigTest.scala new file mode 100644 index 00000000000..6262fd013a3 --- /dev/null +++ b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIServicesConfigTest.scala @@ -0,0 +1,73 @@ +package pl.touk.nussknacker.openapi + +import com.typesafe.config.ConfigFactory +import org.scalatest.OptionValues +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class OpenAPIServicesConfigTest extends AnyFunSuite with Matchers with OptionValues { + + import net.ceedubs.ficus.Ficus._ + import OpenAPIServicesConfig._ + + test("should parse apikey secret for each scheme") { + val config = ConfigFactory.parseString("""url: "http://foo" + |security { + | apikeySecurityScheme { + | type: "apiKey" + | apiKeyValue: "34534asfdasf" + | } + | apikeySecurityScheme2 { + | type: "apiKey" + | apiKeyValue: "123" + | } + |}""".stripMargin) + + val parsedConfig = config.as[OpenAPIServicesConfig] + parsedConfig.securityConfig + .secret(SecuritySchemeName("apikeySecurityScheme")) + .value shouldEqual ApiKeySecret(apiKeyValue = "34534asfdasf") + parsedConfig.securityConfig + .secret(SecuritySchemeName("apikeySecurityScheme2")) + .value shouldEqual ApiKeySecret(apiKeyValue = "123") + } + + test("should parse common apikey secret for any scheme") { + val config = ConfigFactory.parseString("""url: "http://foo" + |secret { + | type: "apiKey" + | apiKeyValue: "34534asfdasf" + |}""".stripMargin) + + val parsedConfig = config.as[OpenAPIServicesConfig] + parsedConfig.securityConfig + .secret(SecuritySchemeName("someScheme")) + .value shouldEqual ApiKeySecret(apiKeyValue = "34534asfdasf") + parsedConfig.securityConfig + .secret(SecuritySchemeName("someOtherScheme")) + .value shouldEqual ApiKeySecret(apiKeyValue = "34534asfdasf") + } + + test("should parse combined apikey secret for each scheme and common apikey secret for any scheme") { + val config = ConfigFactory.parseString("""url: "http://foo" + |security { + | someScheme { + | type: "apiKey" + | apiKeyValue: "123" + | } + |} + |secret { + | type: "apiKey" + | apiKeyValue: "234" + |}""".stripMargin) + + val parsedConfig = config.as[OpenAPIServicesConfig] + parsedConfig.securityConfig + .secret(SecuritySchemeName("someScheme")) + .value shouldEqual ApiKeySecret(apiKeyValue = "123") + parsedConfig.securityConfig + .secret(SecuritySchemeName("someOtherScheme")) + .value shouldEqual ApiKeySecret(apiKeyValue = "234") + } + +} diff --git a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIsConfigTest.scala b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIsConfigTest.scala deleted file mode 100644 index a698eff89f2..00000000000 --- a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/OpenAPIsConfigTest.scala +++ /dev/null @@ -1,26 +0,0 @@ -package pl.touk.nussknacker.openapi - -import com.typesafe.config.ConfigFactory -import org.scalatest.OptionValues -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class OpenAPIsConfigTest extends AnyFunSuite with Matchers with OptionValues { - - import net.ceedubs.ficus.Ficus._ - import OpenAPIsConfig._ - - test("should parse apikey security") { - val config = ConfigFactory.parseString("""url: "http://foo" - |security { - | apikeySecuritySchema { - | type: "apiKey" - | apiKeyValue: "34534asfdasf" - | } - |}""".stripMargin) - - val parsedConfig = config.as[OpenAPIServicesConfig] - parsedConfig.security.value.get("apikeySecuritySchema").value shouldEqual ApiKeyConfig(apiKeyValue = "34534asfdasf") - } - -} diff --git a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/SecurityTest.scala b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/SecurityTest.scala index 28765d28616..e9de5f99285 100644 --- a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/SecurityTest.scala +++ b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/SecurityTest.scala @@ -3,89 +3,162 @@ package pl.touk.nussknacker.openapi import com.typesafe.scalalogging.LazyLogging import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -import org.scalatest.{Assertion, BeforeAndAfterAll} +import org.scalatest.{Assertion, BeforeAndAfterAll, LoneElement, TryValues} import pl.touk.nussknacker.engine.api.ContextId import pl.touk.nussknacker.engine.api.test.EmptyInvocationCollector.Instance import pl.touk.nussknacker.engine.api.typed.TypedMap import pl.touk.nussknacker.test.PatientScalaFutures import sttp.client3.testing.SttpBackendStub -import sttp.client3.{Request, Response} +import sttp.client3.{Request, Response, SttpBackend} import sttp.model.{HeaderNames, StatusCode} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future +import scala.util.Try class SecurityTest extends AnyFunSuite with BeforeAndAfterAll with Matchers + with TryValues + with LoneElement with LazyLogging with PatientScalaFutures with BaseOpenAPITest { - sealed case class Config( - path: String, - securityName: String, - serviceName: String, - key: String, - assertion: Request[_, _] => Assertion - ) + private class StubbedOperationLogic( + val operationId: String, + private val path: List[String], + val securitySchemeName: SecuritySchemeName, + val expectedSecret: ApiKeySecret, + val checkSecret: (Request[_, _], ApiKeySecret) => Assertion + ) { + + def handleMatchingRequest(request: Request[_, _]): Option[Try[Assertion]] = + Option(request).filter(requestMatches).map(_ => Try(checkSecret(request, expectedSecret))) - private val configs = List[Config]( - Config( - "headerPath", - "headerConfig", - "header", - "h1", - _.headers.find(_.name == "keyHeader").map(_.value) shouldBe Some("h1") + private def requestMatches(request: Request[_, _]) = { + request.uri.path == path + } + + } + + private val stubbedSecretCheckingLogics = List[StubbedOperationLogic]( + new StubbedOperationLogic( + "headerOperationId", + "headerPath" :: Nil, + SecuritySchemeName("headerConfig"), + ApiKeySecret("h1"), + (req, expectedSecret) => + req.headers.find(_.name == "keyHeader").map(_.value) shouldBe Some(expectedSecret.apiKeyValue) + ), + new StubbedOperationLogic( + "queryOperationId", + "queryPath" :: Nil, + SecuritySchemeName("queryConfig"), + ApiKeySecret("q1"), + (req, expectedSecret) => req.uri.params.get("keyParam") shouldBe Some(expectedSecret.apiKeyValue) ), - Config("queryPath", "queryConfig", "query", "q1", _.uri.params.get("keyParam") shouldBe Some("q1")), - Config( - "cookiePath", - "cookieConfig", - "cookie", - "c1", - _.headers.find(_.name == HeaderNames.Cookie).map(_.value) shouldBe Some("keyCookie=c1") + new StubbedOperationLogic( + "cookieOperationId", + "cookiePath" :: Nil, + SecuritySchemeName("cookieConfig"), + ApiKeySecret("c1"), + (req, expectedSecret) => + req.headers.find(_.name == HeaderNames.Cookie).map(_.value) shouldBe Some( + s"keyCookie=${expectedSecret.apiKeyValue}" + ) ), ) - test("service returns customers") { - val backend = SttpBackendStub.asynchronousFuture - .whenRequestMatches { request => - val pathMatches = configs.find(_.path == request.uri.path.head) - pathMatches.foreach(_.assertion(request)) - pathMatches.isDefined + private val definitionMatchingStubbedLogic = "service-security.yml" + + val backend: SttpBackend[Future, Any] = SttpBackendStub.asynchronousFuture.whenAnyRequest + .thenRespondF { request => + Future { + val operationsLogicResults = + stubbedSecretCheckingLogics + .flatMap(logic => logic.handleMatchingRequest(request).map(logic.operationId -> _)) + .toMap + operationsLogicResults.loneElement._2.success.value + Response("{}", StatusCode.Ok) } - .thenRespond(Response("{}", StatusCode.Ok)) + } - val withCorrectConfig = - enrichersForSecurityConfig(backend, configs.map(c => c.securityName -> ApiKeyConfig(c.key)).toMap) - configs.foreach { config => - withClue(config.serviceName) { + test("secret configured for each scheme in definition") { + val enricherWithCorrectConfig = + parseToEnrichers( + definitionMatchingStubbedLogic, + backend, + baseConfig.copy( + security = stubbedSecretCheckingLogics.map(c => c.securitySchemeName -> c.expectedSecret).toMap, + ) + ) + stubbedSecretCheckingLogics.foreach { logic => + withClue(logic.operationId) { implicit val contextId: ContextId = ContextId("1") - withCorrectConfig(ServiceName(config.serviceName)) + enricherWithCorrectConfig(ServiceName(logic.operationId)) .invoke(Map.empty) .futureValue shouldBe TypedMap(Map.empty) } } - val withBadConfig = - enrichersForSecurityConfig(backend, configs.map(c => c.securityName -> ApiKeyConfig("bla")).toMap) - configs.foreach { config => - withClue(config.serviceName) { + val enricherWithBadConfig = + parseToEnrichers( + definitionMatchingStubbedLogic, + backend, + baseConfig.copy( + security = stubbedSecretCheckingLogics.map(c => c.securitySchemeName -> ApiKeySecret("bla")).toMap, + ) + ) + stubbedSecretCheckingLogics.foreach { logic => + withClue(logic.operationId) { intercept[Exception] { implicit val contextId: ContextId = ContextId("1") - withBadConfig(ServiceName(config.serviceName)).invoke(Map.empty).futureValue + enricherWithBadConfig(ServiceName(logic.operationId)).invoke(Map.empty).futureValue } } } } - private def enrichersForSecurityConfig( - backend: SttpBackendStub[Future, Any], - securities: Map[String, ApiKeyConfig] - ) = { - parseToEnrichers("service-security.yml", backend, baseConfig.copy(security = Some(securities))) + test("common secret configured for any scheme") { + stubbedSecretCheckingLogics.foreach { config => + withClue(config.operationId) { + val enricherWithSingleSecurityConfig = parseToEnrichers( + definitionMatchingStubbedLogic, + backend, + baseConfig.copy(secret = Some(config.expectedSecret)) + ) + implicit val contextId: ContextId = ContextId("1") + enricherWithSingleSecurityConfig(ServiceName(config.operationId)) + .invoke(Map.empty) + .futureValue shouldBe TypedMap(Map.empty) + } + } + } + + test("common secret configured for any scheme with one operation handling multiple security schemes") { + val secretMatchesEveryScheme = ApiKeySecret("single-secret") + val backend = SttpBackendStub.asynchronousFuture.whenAnyRequest + .thenRespondF { request => + Future { + val operationsLogicResults = + stubbedSecretCheckingLogics + .map(logic => logic.operationId -> Try(logic.checkSecret(request, secretMatchesEveryScheme))) + .toMap + operationsLogicResults.filter(_._2.isSuccess) should have size 1 + Response("{}", StatusCode.Ok) + } + } + val enricherWithSingleSecurityConfig = parseToEnrichers( + "multiple-schemes-for-single-operation.yml", + backend, + baseConfig.copy(secret = Some(secretMatchesEveryScheme)) + ) + implicit val contextId: ContextId = ContextId("1") + enricherWithSingleSecurityConfig(ServiceName("root")) + .invoke(Map.empty) + .futureValue shouldBe TypedMap(Map.empty) } } diff --git a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala index 52149521331..0c9adf8bd94 100644 --- a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala +++ b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala @@ -107,7 +107,7 @@ class SwaggerParserTest extends AnyFunSuite with BaseOpenAPITest with Matchers { errorsFor("noResponseType") shouldBe List("No response with application/json or */* media types found") errorsFor("unhandledSecurity") shouldBe List( - "No security requirement can be met because: there is no security config for scheme name \"headerConfig\"" + "No security requirement can be met because: there is no security secret configured for scheme name \"headerConfig\"" ) errorsFor("unhandledFormat") shouldBe List("Type 'number' in format 'decimal' is not supported") diff --git a/docs/Changelog.md b/docs/Changelog.md index 7e0ae403204..3986d46db21 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -27,6 +27,7 @@ * [#7184](https://github.com/TouK/nussknacker/pull/7184) Improve Nu Designer API notifications endpoint, to include events related to currently displayed scenario * [#7323](https://github.com/TouK/nussknacker/pull/7323) Improve Periodic DeploymentManager db queries * [#7332](https://github.com/TouK/nussknacker/pull/7332) Handle scenario names with spaces when performing migration tests, they were ignored +* [#7346](https://github.com/TouK/nussknacker/pull/7346) OpenAPI enricher: ability to configure common secret for any security scheme ## 1.18 diff --git a/docs/integration/OpenAPI.md b/docs/integration/OpenAPI.md index 66dc2bc85d5..5cfe8442ef5 100644 --- a/docs/integration/OpenAPI.md +++ b/docs/integration/OpenAPI.md @@ -67,15 +67,16 @@ components { } ``` -| Parameter | Required | Default | Description | -|------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| url | true | | URL of the [*OpenAPI interface definition*](https://swagger.io/specification/v3/). It contains definition of the service you want to interact with. | -| rootUrl | false | | The URL of the service. If not specified, the URL of the service is taken from the *OpenAPI interface definition*. | -| allowedMethods | false | ["GET"] | Usually only GET services should be used as enrichers are meant to be idempotent and not change data | -| namePattern | false | .* | Regexp for filtering operations by operationId (i.e. enricher name) | -| security | false | | Configuration for [authentication](https://swagger.io/docs/specification/authentication/) for each `securitySchemas` defined in the *OpenAPI interface definition* | -| security.*.type | false | | Type of security configuration for a given security schema. Currently only `apiKey` is supported | -| security.*.apiKeyValue | false | | API key that will be passed into the service via header, query parameter or cookie (depending on definition provided in OpenAPI) | +| Parameter | Required | Default | Description | +|------------------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| url | true | | URL of the [*OpenAPI interface definition*](https://swagger.io/specification/v3/). It contains definition of the service you want to interact with. | +| rootUrl | false | | The URL of the service. If not specified, the URL of the service is taken from the *OpenAPI interface definition*. | +| allowedMethods | false | ["GET"] | Usually only GET services should be used as enrichers are meant to be idempotent and not change data | +| namePattern | false | .* | Regexp for filtering operations by operationId (i.e. enricher name) | +| security | false | | Configuration for [authentication](https://swagger.io/docs/specification/authentication/) for each `securitySchemas` defined in the *OpenAPI interface definition* | +| security.*.type | false | | Type of security configuration for a given security schema. Currently only `apiKey` is supported | +| security.*.apiKeyValue | false | | API key that will be passed into the service via header, query parameter or cookie (depending on definition provided in OpenAPI) | +| secret | false | | Configuration for [authentication](https://swagger.io/docs/specification/authentication/) which matches any `securitySchemas` defined in the *OpenAPI interface definition*. This config entry has the same structure as values in `security` object (see above) | ## Operations