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) } } }