mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 11:06:49 +00:00
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.
288 lines
13 KiB
Scala
288 lines
13 KiB
Scala
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, 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`
|
|
|
|
import scala.collection.mutable.ArrayBuffer
|
|
|
|
/**
|
|
* ResourceDoc-driven validation middleware for http4s.
|
|
*
|
|
* This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata.
|
|
* Validation is performed in a specific order to ensure security and proper error responses.
|
|
*
|
|
* VALIDATION ORDER:
|
|
* 1. Authentication - Check if user is authenticated (if required by ResourceDoc)
|
|
* 2. Authorization - Verify user has required roles/entitlements
|
|
* 3. Bank validation - Validate BANK_ID path parameter (if present)
|
|
* 4. Account validation - Validate ACCOUNT_ID path parameter (if present)
|
|
* 5. View validation - Validate VIEW_ID and check user access (if present)
|
|
* 6. Counterparty validation - Validate COUNTERPARTY_ID (if present)
|
|
*
|
|
* Validated entities are stored in CallContext fields for use in endpoint handlers.
|
|
*/
|
|
object ResourceDocMiddleware extends MdcLoggable {
|
|
|
|
/** Type alias for http4s OptionT route effect */
|
|
type HttpF[A] = OptionT[IO, A]
|
|
|
|
/** Type alias for validation effect using EitherT */
|
|
type Validation[A] = EitherT[IO, Response[IO], A]
|
|
|
|
/** JSON content type for responses */
|
|
private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json)
|
|
|
|
/**
|
|
* Context that accumulates all validated entities during request processing.
|
|
* This context is passed along the validation chain.
|
|
*/
|
|
final case class ValidationContext(
|
|
user: Box[User] = Empty,
|
|
callContext: CallContext,
|
|
bank: Option[Bank] = None,
|
|
account: Option[BankAccount] = None,
|
|
view: Option[View] = None,
|
|
counterparty: Option[CounterpartyTrait] = None
|
|
)
|
|
|
|
/** Simple DSL for success/failure in the validation chain */
|
|
object DSL {
|
|
def success[A](a: A): Validation[A] = EitherT.rightT(a)
|
|
def failure(resp: Response[IO]): Validation[Nothing] = EitherT.leftT(resp)
|
|
}
|
|
|
|
/**
|
|
* Check if ResourceDoc requires authentication.
|
|
*
|
|
* Authentication is required if:
|
|
* - ResourceDoc errorResponseBodies contains $AuthenticatedUserIsRequired
|
|
* - ResourceDoc has roles (roles always require authenticated user)
|
|
* - Special case: resource-docs endpoint checks resource_docs_requires_role property
|
|
*/
|
|
private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = {
|
|
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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Middleware factory: wraps HttpRoutes with ResourceDoc validation.
|
|
* Finds the matching ResourceDoc, validates the request, and enriches CallContext.
|
|
*/
|
|
def apply(resourceDocs: ArrayBuffer[ResourceDoc]): HttpRoutes[IO] => HttpRoutes[IO] = { routes =>
|
|
Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
|
|
// Build initial CallContext from request
|
|
OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, "v7.0.0")).flatMap { cc =>
|
|
ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) match {
|
|
case Some(resourceDoc) =>
|
|
val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc)
|
|
val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc)
|
|
// Run full validation chain
|
|
OptionT(validateRequest(req, resourceDoc, pathParams, ccWithDoc, routes).map(Option(_)))
|
|
|
|
case None =>
|
|
// No matching ResourceDoc: fallback to original route
|
|
routes.run(req)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes the full validation chain for the request.
|
|
* Returns either an error Response or enriched request routed to the handler.
|
|
*/
|
|
private def validateRequest(
|
|
req: Request[IO],
|
|
resourceDoc: ResourceDoc,
|
|
pathParams: Map[String, String],
|
|
cc: CallContext,
|
|
routes: HttpRoutes[IO]
|
|
): IO[Response[IO]] = {
|
|
|
|
// Initial context with just CallContext
|
|
val initialContext = ValidationContext(callContext = cc)
|
|
|
|
// Compose all validation steps using EitherT
|
|
val result: Validation[ValidationContext] = for {
|
|
context <- authenticate(req, resourceDoc, initialContext)
|
|
context <- authorizeRoles(resourceDoc, pathParams, context)
|
|
context <- validateBank(pathParams, context)
|
|
context <- validateAccount(pathParams, context)
|
|
context <- validateView(pathParams, context)
|
|
context <- validateCounterparty(pathParams, context)
|
|
} yield context
|
|
|
|
// Convert Validation result to Response
|
|
result.value.flatMap {
|
|
case Left(errorResponse) => IO.pure(ensureJsonContentType(errorResponse)) // Ensure all error responses are JSON
|
|
case Right(validCtx) =>
|
|
// Enrich request with validated CallContext
|
|
val enrichedReq = req.withAttribute(
|
|
Http4sRequestAttributes.callContextKey,
|
|
validCtx.callContext.copy(
|
|
bank = validCtx.bank,
|
|
bankAccount = validCtx.account,
|
|
view = validCtx.view,
|
|
counterparty = validCtx.counterparty
|
|
)
|
|
)
|
|
routes.run(enrichedReq)
|
|
.map(ensureJsonContentType) // Ensure routed response has JSON content type
|
|
.getOrElseF(IO.pure(ensureJsonContentType(Response[IO](org.http4s.Status.NotFound))))
|
|
}
|
|
}
|
|
|
|
/** Authentication step: verifies user and updates ValidationContext */
|
|
private def authenticate(req: Request[IO], resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = {
|
|
val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc)
|
|
logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth")
|
|
|
|
val io =
|
|
if (needsAuth) IO.fromFuture(IO(APIUtil.authenticatedAccess(ctx.callContext)))
|
|
else IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext)))
|
|
|
|
EitherT(
|
|
io.attempt.flatMap {
|
|
case Right((boxUser, Some(updatedCC))) =>
|
|
IO.pure(Right(ctx.copy(user = boxUser, callContext = updatedCC)))
|
|
case Right((boxUser, None)) =>
|
|
IO.pure(Right(ctx.copy(user = boxUser)))
|
|
case Left(e: APIFailureNewStyle) =>
|
|
ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
|
case Left(_) =>
|
|
ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext).map(Left(_))
|
|
}
|
|
)
|
|
}
|
|
|
|
/** Role authorization step: ensures user has required roles */
|
|
private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
|
import DSL._
|
|
|
|
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) =>
|
|
val bankId = pathParams.getOrElse("BANK_ID", "")
|
|
val ok = roles.exists { role =>
|
|
val checkBankId = if (role.requiresBankId) bankId else ""
|
|
APIUtil.hasEntitlement(checkBankId, user.userId, role)
|
|
}
|
|
if (ok) success(ctx)
|
|
else EitherT[IO, Response[IO], ValidationContext](
|
|
ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), ctx.callContext)
|
|
.map[Either[Response[IO], ValidationContext]](Left(_))
|
|
)
|
|
case _ =>
|
|
EitherT[IO, Response[IO], ValidationContext](
|
|
ErrorResponseConverter
|
|
.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext)
|
|
.map[Either[Response[IO], ValidationContext]](resp => Left(resp))
|
|
)
|
|
}
|
|
case _ => success(ctx)
|
|
}
|
|
}
|
|
|
|
/** Bank validation: checks BANK_ID and fetches bank */
|
|
private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
|
|
|
pathParams.get("BANK_ID") match {
|
|
case Some(bankId) =>
|
|
EitherT(
|
|
IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext))))
|
|
.attempt.flatMap {
|
|
case Right((bank, Some(updatedCC))) => IO.pure(Right(ctx.copy(bank = Some(bank), callContext = updatedCC)))
|
|
case Right((bank, None)) => IO.pure(Right(ctx.copy(bank = Some(bank))))
|
|
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
|
case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankNotFound + s": $bankId", ctx.callContext).map(Left(_))
|
|
}
|
|
)
|
|
case None => DSL.success(ctx)
|
|
}
|
|
}
|
|
|
|
/** Account validation: checks ACCOUNT_ID and fetches bank account */
|
|
private def validateAccount(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
|
|
|
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match {
|
|
case (Some(bankId), Some(accountId)) =>
|
|
EitherT(
|
|
IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(ctx.callContext))))
|
|
.attempt.flatMap {
|
|
case Right((acc, Some(updatedCC))) => IO.pure(Right(ctx.copy(account = Some(acc), callContext = updatedCC)))
|
|
case Right((acc, None)) => IO.pure(Right(ctx.copy(account = Some(acc))))
|
|
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
|
case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankId, accountId=$accountId", ctx.callContext).map(Left(_))
|
|
}
|
|
)
|
|
case _ => DSL.success(ctx)
|
|
}
|
|
}
|
|
|
|
/** View validation: checks VIEW_ID and user access */
|
|
private def validateView(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
|
|
|
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match {
|
|
case (Some(bankId), Some(accountId), Some(viewId)) =>
|
|
EitherT(
|
|
IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), ctx.user.toOption, Some(ctx.callContext))))
|
|
.attempt.flatMap {
|
|
case Right(view) => IO.pure(Right(ctx.copy(view = Some(view))))
|
|
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
|
case Left(_) => ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewId", ctx.callContext).map(Left(_))
|
|
}
|
|
)
|
|
case _ => DSL.success(ctx)
|
|
}
|
|
}
|
|
|
|
/** Counterparty validation: checks COUNTERPARTY_ID and fetches counterparty */
|
|
private def validateCounterparty(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = {
|
|
|
|
(pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match {
|
|
case (Some(bankId), Some(accountId), Some(counterpartyId)) =>
|
|
EitherT(
|
|
IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankId), AccountId(accountId), counterpartyId, Some(ctx.callContext))))
|
|
.attempt.flatMap {
|
|
case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC)))
|
|
case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp))))
|
|
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
|
|
case Left(_) => ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyId", ctx.callContext).map(Left(_))
|
|
}
|
|
)
|
|
case _ => DSL.success(ctx)
|
|
}
|
|
}
|
|
|
|
/** Ensure the response has JSON content type */
|
|
private def ensureJsonContentType(response: Response[IO]): Response[IO] = {
|
|
response.contentType match {
|
|
case Some(contentType) if contentType.mediaType == MediaType.application.json => response
|
|
case _ => response.withContentType(jsonContentType)
|
|
}
|
|
}
|
|
}
|