refactor/(resource-docs): enforce canReadResourceDoc role via middleware

Move role-based authorization for resource-docs endpoint from endpoint implementation to ResourceDocMiddleware. This ensures consistent authentication handling across all endpoints and removes duplicate authorization logic.

The middleware now checks the `resource_docs_requires_role` property and enforces the `canReadResourceDoc` role when enabled. Tests are updated to verify proper 403 responses with missing role messages.
This commit is contained in:
hongwei 2026-01-26 14:29:54 +01:00
parent 73b9ecb591
commit 5ff7b299a6
3 changed files with 59 additions and 63 deletions

View File

@ -2,13 +2,15 @@ package code.api.util.http4s
import cats.data.{EitherT, Kleisli, OptionT}
import cats.effect._
import code.api.v7_0_0.Http4s700
import code.api.APIFailureNewStyle
import code.api.util.APIUtil.ResourceDoc
import code.api.util.ErrorMessages._
import code.api.util.newstyle.ViewNewStyle
import code.api.util.{APIUtil, CallContext, NewStyle}
import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle}
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.model._
import com.github.dwickern.macros.NameOf.nameOf
import net.liftweb.common.{Box, Empty, Full}
import org.http4s._
import org.http4s.headers.`Content-Type`
@ -70,7 +72,7 @@ object ResourceDocMiddleware extends MdcLoggable {
* - Special case: resource-docs endpoint checks resource_docs_requires_role property
*/
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") {
if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700)) {
APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)
} else {
resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty)
@ -172,7 +174,14 @@ object ResourceDocMiddleware extends MdcLoggable {
private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
import DSL._
resourceDoc.roles match {
val rolesToCheck: Option[List[ApiRole]] =
if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700) && APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)) {
Some(List(ApiRole.canReadResourceDoc))
} else {
resourceDoc.roles
}
rolesToCheck match {
case Some(roles) if roles.nonEmpty =>
ctx.user match {
case Full(user) =>

View File

@ -6,12 +6,12 @@ import code.api.Constant._
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._
import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil}
import code.api.util.APIUtil.{EmptyBody, _}
import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc}
import code.api.util.ApiRole.canGetCardsForBank
import code.api.util.ApiTag._
import code.api.util.ErrorMessages._
import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, ResourceDocMiddleware}
import code.api.util.http4s.{Http4sRequestAttributes, ResourceDocMiddleware}
import code.api.util.http4s.Http4sRequestAttributes.{RequestOps, EndpointHelpers}
import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
import code.api.util.{ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle}
import code.api.v1_3_0.JSONFactory1_3_0
import code.api.v1_4_0.JSONFactory1_4_0
import code.api.v4_0_0.JSONFactory400
@ -201,62 +201,25 @@ object Http4s700 {
val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" =>
implicit val cc: CallContext = req.callContext
val resultF: Future[String] = {
val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false)
EndpointHelpers.executeAndRespond(req) { _ =>
val queryParams = req.uri.query.multiParams
val tags = queryParams
.get("tags")
.map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList)
val functions = queryParams
.get("functions")
.map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList)
val localeParam = queryParams
.get("locale")
.flatMap(_.headOption)
.orElse(queryParams.get("language").flatMap(_.headOption))
.map(_.trim)
.filter(_.nonEmpty)
for {
(boxUser, cc1) <- if (resourceDocsRequireRole)
authenticatedAccess(cc)
else
anonymousAccess(cc)
_ <- if (resourceDocsRequireRole) {
NewStyle.function.hasAtLeastOneEntitlement(
failMsg = UserHasMissingRoles + canReadResourceDoc.toString
)("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1)
} else {
Future.successful(())
}
queryParams = req.uri.query.multiParams
tags = queryParams
.get("tags")
.map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList)
functions = queryParams
.get("functions")
.map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList)
localeParam = queryParams
.get("locale")
.flatMap(_.headOption)
.orElse(queryParams.get("language").flatMap(_.headOption))
.map(_.trim)
.filter(_.nonEmpty)
contentParam = queryParams
.get("content")
.flatMap(_.headOption)
.map(_.trim)
.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam)
requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString))
resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil)
filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions)
resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam)
} yield convertAnyToJsonString(resourceDocsJson)
}
IO.fromFuture(IO(resultF)).attempt.flatMap {
case Right(result) => Ok(result)
case Left(e) =>
val (code, msg) = try {
import net.liftweb.json._
implicit val formats = net.liftweb.json.DefaultFormats
val json = parse(e.getMessage)
val failCode = (json \ "failCode").extractOpt[Int].getOrElse(400)
val failMsg = (json \ "failMsg").extractOpt[String].getOrElse(UnknownError)
(failCode, failMsg)
} catch {
case _: Exception => (500, UnknownError)
}
ErrorResponseConverter.createErrorResponse(code, msg, cc)
} yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam)
}
}

View File

@ -3,7 +3,7 @@ package code.api.v7_0_0
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc}
import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound}
import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles}
import code.setup.ServerSetupWithTestData
import net.liftweb.json.JValue
import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString}
@ -180,10 +180,22 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
val request = withDirectLoginToken(baseRequest, token1.value)
When("Running through wrapped routes")
val (status, _) = runAndParseJson(request)
val (status, json) = runAndParseJson(request)
Then("Response is 403 Forbidden")
status.code shouldBe 403
json match {
case JObject(fields) =>
toFieldMap(fields).get("message") match {
case Some(JString(message)) =>
message should include(UserHasMissingRoles)
message should include(canGetCardsForBank.toString)
case _ =>
fail("Expected message field as JSON string for missing-role response")
}
case _ =>
fail("Expected JSON object for missing-role response")
}
}
scenario("Return BankNotFound when bank does not exist and user is entitled", Http4s700RoutesTag) {
@ -280,10 +292,22 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
val request = withDirectLoginToken(baseRequest, token1.value)
When("Running through wrapped routes")
val (status, _) = runAndParseJson(request)
val (status, json) = runAndParseJson(request)
Then("Response is 400 Bad Request")
status.code shouldBe 400
Then("Response is 403 Forbidden")
status.code shouldBe 403
json match {
case JObject(fields) =>
toFieldMap(fields).get("message") match {
case Some(JString(message)) =>
message should include(UserHasMissingRoles)
message should include(canReadResourceDoc.toString)
case _ =>
fail("Expected message field as JSON string for missing-role response")
}
case _ =>
fail("Expected JSON object for missing-role response")
}
}
scenario("Return docs when authenticated and entitled with canReadResourceDoc", Http4s700RoutesTag) {