This commit is contained in:
Marko Milić 2026-02-04 08:46:44 +00:00 committed by GitHub
commit cc8f0e5e5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 170 additions and 19 deletions

View File

@ -1047,6 +1047,8 @@ featured_apis=elasticSearchWarehouseV300
# rabbitmq_connector.username=obp
# rabbitmq_connector.password=obp
# rabbitmq_connector.virtual_host=/
# rabbitmq_connector.request_queue=obp_rpc_queue
# rabbitmq_connector.response_queue_prefix=obp_reply_queue
# -- RabbitMQ Adapter --------------------------------------------
#rabbitmq.adapter.enabled=false

View File

@ -2,7 +2,7 @@ package code.api.ResourceDocs1_4_0
import scala.language.reflectiveCalls
import code.api.Constant.HostName
import code.api.OBPRestHelper
import code.api.{OBPRestHelper, ResponseHeader}
import code.api.cache.Caching
import code.api.util.APIUtil._
import code.api.util.{APIUtil, ApiVersionUtils, YAMLUtils}
@ -236,7 +236,7 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md
yamlResult
}
val headers = List("Content-Type" -> YAMLUtils.getYAMLContentType)
val headers = List("Content-Type" -> YAMLUtils.getYAMLContentType, (ResponseHeader.`Correlation-Id` -> getCorrelationId()))
val bytes = yamlString.getBytes("UTF-8")
InMemoryResponse(bytes, headers, Nil, 200)
}

View File

@ -677,6 +677,16 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
implicit val ec = EndpointContext(Some(cc))
val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams()
for {
(u: Box[User], callContext: Option[CallContext]) <- if (resourceDocsRequireRole) {
authenticatedAccess(cc)
} else {
anonymousAccess(cc)
}
_ <- if (resourceDocsRequireRole) {
NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext)
} else {
Future(())
}
requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString Current Version is $requestedApiVersionString", 400, cc.callContext) {
ApiVersionUtils.valueOf(requestedApiVersionString)
}
@ -871,6 +881,16 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth
} else {
Future.successful(true)
}
(u: Box[User], callContext: Option[CallContext]) <- if (resourceDocsRequireRole) {
authenticatedAccess(cc)
} else {
anonymousAccess(cc)
}
_ <- if (resourceDocsRequireRole) {
NewStyle.function.hasAtLeastOneEntitlement(failMsg = UserHasMissingRoles + canReadResourceDoc.toString)("", u.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc.callContext)
} else {
Future(())
}
requestedApiVersion <- NewStyle.function.tryons(s"$InvalidApiVersionString Current Version is $requestedApiVersionString", 400, cc.callContext) {
ApiVersionUtils.valueOf(requestedApiVersionString)
}

View File

@ -53,8 +53,8 @@ object RabbitMQUtils extends MdcLoggable{
private implicit val formats = code.api.util.CustomJsonFormats.nullTolerateFormats
val RPC_QUEUE_NAME: String = "obp_rpc_queue"
val RPC_REPLY_TO_QUEUE_NAME_PREFIX: String = "obp_reply_queue"
val RPC_QUEUE_NAME: String = APIUtil.getPropsValue("rabbitmq_connector.request_queue", "obp_rpc_queue")
val RPC_REPLY_TO_QUEUE_NAME_PREFIX: String = APIUtil.getPropsValue("rabbitmq_connector.response_queue_prefix", "obp_reply_queue")
class ResponseCallback(val rabbitCorrelationId: String, channel: Channel) extends DeliverCallback {
@ -92,14 +92,30 @@ object RabbitMQUtils extends MdcLoggable{
val rabbitRequestJsonString: String = write(outBound) // convert OutBound to json string
val connection = RabbitMQConnectionPool.borrowConnection()
// Check if queue already exists using a temporary channel (passive declare closes channel on failure)
val queueExists = try {
val tempChannel = connection.createChannel()
try {
tempChannel.queueDeclarePassive(RPC_QUEUE_NAME)
true
} finally {
if (tempChannel.isOpen) tempChannel.close()
}
} catch {
case _: java.io.IOException => false
}
val channel = connection.createChannel() // channel is not thread safe, so we always create new channel for each message.
channel.queueDeclare(
RPC_QUEUE_NAME, // Queue name
true, // durable: non-persis, here set durable = true
false, // exclusive: non-excl4, here set exclusive = false
false, // autoDelete: delete, here set autoDelete = false
rpcQueueArgs // extra arguments,
)
// Only declare queue if it doesn't already exist (avoids argument conflicts with external adapters)
if (!queueExists) {
channel.queueDeclare(
RPC_QUEUE_NAME, // Queue name
true, // durable: non-persis, here set durable = true
false, // exclusive: non-excl4, here set exclusive = false
false, // autoDelete: delete, here set autoDelete = false
rpcQueueArgs // extra arguments,
)
}
val replyQueueName:String = channel.queueDeclare(
s"${RPC_REPLY_TO_QUEUE_NAME_PREFIX}_${messageId.replace("obp_","")}_${UUID.randomUUID.toString}", // Queue name, it will be a unique name for each queue
@ -112,6 +128,7 @@ object RabbitMQUtils extends MdcLoggable{
val rabbitResponseJsonFuture = {
try {
logger.debug(s"${RabbitMQConnector_vOct2024.toString} outBoundJson: $messageId = $rabbitRequestJsonString")
logger.info(s"[RabbitMQ] Sending message to queue: $RPC_QUEUE_NAME, messageId: $messageId, replyTo: $replyQueueName")
val rabbitMQCorrelationId = UUID.randomUUID().toString
val rabbitMQProps = new BasicProperties.Builder()
@ -121,6 +138,7 @@ object RabbitMQUtils extends MdcLoggable{
.replyTo(replyQueueName)
.build()
channel.basicPublish("", RPC_QUEUE_NAME, rabbitMQProps, rabbitRequestJsonString.getBytes("UTF-8"))
logger.info(s"[RabbitMQ] Message published, correlationId: $rabbitMQCorrelationId, waiting for response on: $replyQueueName")
val responseCallback = new ResponseCallback(rabbitMQCorrelationId, channel)
channel.basicConsume(replyQueueName, true, responseCallback, cancelCallback)

View File

@ -1,9 +1,12 @@
package code.api.ResourceDocs1_4_0
import code.api.ResourceDocs1_4_0.ResourceDocs140.ImplementationsResourceDocs
import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles}
import code.api.util.{ApiRole, CustomJsonFormats}
import code.setup.{DefaultUsers, PropsReset}
import com.github.dwickern.macros.NameOf.nameOf
import code.api.util.APIUtil.OAuth._
import code.entitlement.Entitlement
import com.openbankproject.commons.util.{ApiVersion, Functions}
import io.swagger.parser.OpenAPIParser
import net.liftweb.json
@ -256,4 +259,83 @@ class SwaggerDocsTest extends ResourceDocsV140ServerSetup with PropsReset with D
(errors, warnings, allMessages)
}
}
// Additional tests to verify that the Swagger/OpenAPI endpoints respect the resource_docs_requires_role prop.
// These are minimal checks that mirror the behaviour validated elsewhere (Lift/http4s tests).
feature(s"Swagger & OpenAPI access control for resource_docs_requires_role") {
scenario("Swagger - public access when resource_docs_requires_role is false", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "false",
)
val requestGetSwagger = (ResourceDocsV5_1Request / "resource-docs" / "v5.1.0" / "swagger").GET
val responseGetSwagger = makeGetRequest(requestGetSwagger)
responseGetSwagger.code should equal(200)
}
scenario("Swagger - unauthenticated rejected when resource_docs_requires_role is true", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "true",
)
val requestGetSwagger = (ResourceDocsV5_1Request / "resource-docs" / "v5.1.0" / "swagger").GET
val responseGetSwagger = makeGetRequest(requestGetSwagger)
// Lift endpoints typically return 401 with AuthenticatedUserIsRequired message when auth required
responseGetSwagger.code should equal(401)
responseGetSwagger.body.toString should include(AuthenticatedUserIsRequired)
}
scenario("Swagger - authenticated but missing role gets 403", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "true",
)
val requestGetSwagger = (ResourceDocsV5_1Request / "resource-docs" / "v5.1.0" / "swagger").GET <@ (user1)
val responseGetSwagger = makeGetRequest(requestGetSwagger)
responseGetSwagger.code should equal(403)
responseGetSwagger.body.toString should include(UserHasMissingRoles)
responseGetSwagger.body.toString should include(ApiRole.canReadResourceDoc.toString())
}
scenario("Swagger - authenticated and entitled canReadResourceDoc returns 200", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "true",
)
// grant the entitlement to the resource user used in tests
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canReadResourceDoc.toString)
val requestGetSwagger = (ResourceDocsV5_1Request / "resource-docs" / "v5.1.0" / "swagger").GET <@ (user1)
val responseGetSwagger = makeGetRequest(requestGetSwagger)
responseGetSwagger.code should equal(200)
}
// OpenAPI JSON checks (v6.0.0 used elsewhere for OpenAPI tests)
scenario("OpenAPI JSON - public access when resource_docs_requires_role is false", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "false",
)
val requestGetOpenAPI = (ResourceDocsV6_0Request / "resource-docs" / "v6.0.0" / "openapi").GET <<? List(("tags", "Consumer"))
val responseGetOpenAPI = makeGetRequest(requestGetOpenAPI)
responseGetOpenAPI.code should equal(200)
}
scenario("OpenAPI JSON - unauthenticated rejected when resource_docs_requires_role is true", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "true",
)
val requestGetOpenAPI = (ResourceDocsV6_0Request / "resource-docs" / "v6.0.0" / "openapi").GET <<? List(("tags", "Consumer"))
val responseGetOpenAPI = makeGetRequest(requestGetOpenAPI)
responseGetOpenAPI.code should equal(401)
responseGetOpenAPI.body.toString should include(AuthenticatedUserIsRequired)
}
scenario("OpenAPI YAML - raw response: public access when resource_docs_requires_role is false", ApiEndpoint1, VersionOfApi) {
setPropsValues(
"resource_docs_requires_role" -> "false",
)
val requestGetOpenAPIYAML = (ResourceDocsV6_0Request / "resource-docs" / "v6.0.0" / "openapi.yaml").GET <<? List(("tags", "Consumer"))
val responseGetOpenAPIYAML = makeGetRequest(requestGetOpenAPIYAML)
responseGetOpenAPIYAML.code should equal(200)
// body should be non-empty YAML
responseGetOpenAPIYAML.body.toString.trim.nonEmpty should be (true)
}
}
}

View File

@ -186,16 +186,45 @@ trait SendServerRequests {
// Check that every response has a correlationId at Response Header
val list = response.getHeaders(ResponseHeader.`Correlation-Id`).asScala.toList
list match {
case Nil => throw new Exception(s"There is no ${ResponseHeader.`Correlation-Id`} in response header. Couldn't parse response from ${req.url} : $body")
case Nil =>
// Improve diagnostic information: include HTTP status, all response headers and a snippet of the body.
val status = response.getStatusCode
val headersStr = try {
// response.getHeaders().entries() returns a Java collection of header entries
response.getHeaders().entries().asScala.map(h => s"${h.getKey}: ${h.getValue}").mkString(", ")
} catch {
case _: Throwable => "unable to read headers"
}
val bodySnippet = if (body == null) {
""
} else {
val maxLen = 1000
if (body.length > maxLen) body.take(maxLen) + "..." else body
}
throw new Exception(
s"""There is no ${ResponseHeader.`Correlation-Id`} in response header.
|Couldn't parse response from ${req.url}
|status=$status
|headers=[$headersStr]
|body-snippet=${bodySnippet}""".stripMargin
)
case _ =>
}
val parsedBody = tryo {
parse(body)
}
parsedBody match {
case Full(b) => APIResponse(response.getStatusCode, b, Some(response.getHeaders()))
case _ => throw new Exception(s"couldn't parse response from ${req.url} : $body")
// Handle YAML responses: don't try to parse as JSON. Wrap YAML as a JString so tests
// that expect a JValue can still receive the body.
val contentTypeList = response.getHeaders("Content-Type").asScala.toList.map(_.toLowerCase)
val isYaml = contentTypeList.exists(_.contains("yaml"))
if (isYaml) {
APIResponse(response.getStatusCode, JString(body), Some(response.getHeaders()))
} else {
val parsedBody = tryo {
parse(body)
}
parsedBody match {
case Full(b) => APIResponse(response.getStatusCode, b, Some(response.getHeaders()))
case _ => throw new Exception(s"couldn't parse response from ${req.url} : $body")
}
}
}
}