diff --git a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index b705dfc74..856b0f1ee 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -13,7 +13,16 @@ import org.typelevel.ci.CIString /** * Converts OBP errors to http4s Response[IO]. - * Uses Lift JSON for serialization (consistent with OBP codebase). + * + * Handles: + * - APIFailureNewStyle (structured errors with code and message) + * - Box Failure (Lift framework errors) + * - Unknown exceptions + * + * All responses include: + * - JSON body with code and message + * - Correlation-Id header for request tracing + * - Appropriate HTTP status code */ object ErrorResponseConverter { import net.liftweb.json.Formats @@ -23,7 +32,7 @@ object ErrorResponseConverter { private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) /** - * OBP standard error response format + * OBP standard error response format. */ case class OBPErrorResponse( code: Int, @@ -31,7 +40,7 @@ object ErrorResponseConverter { ) /** - * Convert error response to JSON string + * Convert error response to JSON string using Lift JSON. */ private def toJsonString(error: OBPErrorResponse): String = { val json = ("code" -> error.code) ~ ("message" -> error.message) @@ -39,19 +48,18 @@ object ErrorResponseConverter { } /** - * Convert an error to http4s Response[IO] + * Convert any error to http4s Response[IO]. */ def toHttp4sResponse(error: Throwable, callContext: CallContext): IO[Response[IO]] = { error match { - case e: APIFailureNewStyle => - apiFailureToResponse(e, callContext) - case e => - unknownErrorToResponse(e, callContext) + case e: APIFailureNewStyle => apiFailureToResponse(e, callContext) + case _ => unknownErrorToResponse(error, callContext) } } /** - * Convert APIFailureNewStyle to http4s Response + * Convert APIFailureNewStyle to http4s Response. + * Uses failCode as HTTP status and failMsg as error message. */ def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = { val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg) @@ -65,7 +73,8 @@ object ErrorResponseConverter { } /** - * Convert Box Failure to http4s Response + * Convert Lift Box Failure to http4s Response. + * Returns 400 Bad Request with failure message. */ def boxFailureToResponse(failure: LiftFailure, callContext: CallContext): IO[Response[IO]] = { val errorJson = OBPErrorResponse(400, failure.msg) @@ -78,7 +87,8 @@ object ErrorResponseConverter { } /** - * Convert unknown error to http4s Response + * Convert unknown error to http4s Response. + * Returns 500 Internal Server Error. */ def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = { val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}") @@ -91,7 +101,7 @@ object ErrorResponseConverter { } /** - * Create error response with specific status code and message + * Create error response with specific status code and message. */ def createErrorResponse(statusCode: Int, message: String, callContext: CallContext): IO[Response[IO]] = { val errorJson = OBPErrorResponse(statusCode, message) diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 60fd2680f..f231ba002 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -22,57 +22,56 @@ import scala.concurrent.Future import scala.language.higherKinds /** - * Http4s support for ResourceDoc-driven validation. + * Http4s support utilities for OBP API. * - * This file contains: - * - Http4sCallContextBuilder: Builds shared CallContext from http4s Request[IO] - * - Http4sRequestAttributes: Provides CallContext access from http4s requests - * - ResourceDocMatcher: Matches http4s requests to ResourceDoc entries + * This file contains three main components: * - * Validated entities (User, Bank, BankAccount, View, Counterparty) are stored - * directly in CallContext fields, making them available throughout the call chain. + * 1. Http4sRequestAttributes: Request attribute management and endpoint helpers + * - Stores CallContext in http4s request Vault + * - Provides helper methods to simplify endpoint implementations + * - Validated entities are stored in CallContext fields + * + * 2. Http4sCallContextBuilder: Builds CallContext from http4s Request[IO] + * - Extracts headers, auth params, and request metadata + * - Supports DirectLogin, OAuth, and Gateway authentication + * + * 3. ResourceDocMatcher: Matches requests to ResourceDoc entries + * - Finds ResourceDoc by HTTP verb and URL pattern + * - Extracts path parameters (BANK_ID, ACCOUNT_ID, etc.) + * - Attaches ResourceDoc to CallContext for metrics/rate limiting */ /** - * Request attribute keys and helpers for accessing CallContext in http4s requests. + * Request attributes and helper methods for http4s endpoints. * - * CallContext is stored in http4s request attributes using Vault (type-safe key-value store). - * Validated entities (bank, bankAccount, view, counterparty) are stored within CallContext itself. - * - * Usage in endpoints: - * {{{ - * import Http4sRequestAttributes.RequestOps - * - * val myEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { - * case req @ GET -> Root / "banks" => - * implicit val cc: CallContext = req.callContext - * for { - * result <- yourBusinessLogic // cc is implicitly available - * response <- Ok(result) - * } yield response - * } - * }}} + * CallContext is stored in request attributes using http4s Vault (type-safe key-value store). + * Validated entities (user, bank, bankAccount, view, counterparty) are stored within CallContext. */ object Http4sRequestAttributes { - import org.typelevel.vault.Key /** * Vault key for storing CallContext in http4s request attributes. - * CallContext contains all request data and validated entities. + * CallContext contains request data and validated entities (user, bank, account, view, counterparty). */ val callContextKey: Key[CallContext] = Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) /** - * Implicit class that adds CallContext accessor to Request[IO]. - * Import RequestOps to enable `req.callContext` syntax. + * Implicit class that adds .callContext accessor to Request[IO]. + * + * Usage: + * {{{ + * import Http4sRequestAttributes.RequestOps + * + * case req @ GET -> Root / "banks" => + * implicit val cc: CallContext = req.callContext + * // Use cc for business logic + * }}} */ implicit class RequestOps(val req: Request[IO]) extends AnyVal { /** * Extract CallContext from request attributes. - * Throws RuntimeException if CallContext is not found (should never happen with ResourceDocMiddleware). - * - * @return CallContext containing validated user, bank, account, view, counterparty + * Throws RuntimeException if not found (should never happen with ResourceDocMiddleware). */ def callContext: CallContext = { req.attributes.lookup(callContextKey).getOrElse( @@ -82,31 +81,26 @@ object Http4sRequestAttributes { } /** - * Helper methods to simplify endpoint implementations. - * These eliminate boilerplate for common patterns in http4s endpoints. + * Helper methods to eliminate boilerplate in endpoint implementations. + * + * These methods handle: + * - CallContext extraction from request + * - User/Bank extraction from CallContext + * - Future execution with IO.fromFuture + * - JSON serialization with Lift JSON + * - Ok response creation */ object EndpointHelpers { import net.liftweb.json.{Extraction, Formats} import net.liftweb.json.JsonAST.prettyRender /** - * Execute a Future-based business logic function and return JSON response. - * Handles Future execution, JSON conversion, and Ok response creation. + * Execute Future-based business logic and return JSON response. * - * Usage: - * {{{ - * case req @ GET -> Root / "banks" => - * executeAndRespond(req) { implicit cc => - * for { - * (banks, callContext) <- NewStyle.function.getBanks(Some(cc)) - * } yield JSONFactory400.createBanksJson(banks) - * } - * }}} + * Handles: Future execution, JSON conversion, Ok response. * - * @param req The http4s request - * @param f Business logic function that takes CallContext and returns Future[A] - * @param formats Implicit JSON formats for serialization - * @tparam A The result type (will be converted to JSON) + * @param req http4s request + * @param f Business logic: CallContext => Future[A] * @return IO[Response[IO]] with JSON body */ def executeAndRespond[A](req: Request[IO])(f: CallContext => Future[A])(implicit formats: Formats): IO[Response[IO]] = { @@ -119,23 +113,12 @@ object Http4sRequestAttributes { } /** - * Execute business logic that requires validated User from CallContext. - * Extracts User from CallContext, executes business logic, and returns JSON response. + * Execute business logic requiring validated User. * - * Usage: - * {{{ - * case req @ GET -> Root / "cards" => - * withUser(req) { (user, cc) => - * for { - * (cards, callContext) <- NewStyle.function.getPhysicalCardsForUser(user, Some(cc)) - * } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user) - * } - * }}} + * Extracts User from CallContext, executes logic, returns JSON response. * - * @param req The http4s request - * @param f Business logic function that takes (User, CallContext) and returns Future[A] - * @param formats Implicit JSON formats for serialization - * @tparam A The result type (will be converted to JSON) + * @param req http4s request + * @param f Business logic: (User, CallContext) => Future[A] * @return IO[Response[IO]] with JSON body */ def withUser[A](req: Request[IO])(f: (User, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = { @@ -149,23 +132,12 @@ object Http4sRequestAttributes { } /** - * Execute business logic that requires validated Bank from CallContext. - * Extracts Bank from CallContext, executes business logic, and returns JSON response. + * Execute business logic requiring validated Bank. * - * Usage: - * {{{ - * case req @ GET -> Root / "banks" / bankId / "accounts" => - * withBank(req) { (bank, cc) => - * for { - * (accounts, callContext) <- NewStyle.function.getBankAccounts(bank, Some(cc)) - * } yield JSONFactory400.createAccountsJson(accounts) - * } - * }}} + * Extracts Bank from CallContext, executes logic, returns JSON response. * - * @param req The http4s request - * @param f Business logic function that takes (Bank, CallContext) and returns Future[A] - * @param formats Implicit JSON formats for serialization - * @tparam A The result type (will be converted to JSON) + * @param req http4s request + * @param f Business logic: (Bank, CallContext) => Future[A] * @return IO[Response[IO]] with JSON body */ def withBank[A](req: Request[IO])(f: (Bank, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = { @@ -179,23 +151,12 @@ object Http4sRequestAttributes { } /** - * Execute business logic that requires both User and Bank from CallContext. - * Extracts both from CallContext, executes business logic, and returns JSON response. + * Execute business logic requiring both User and Bank. * - * Usage: - * {{{ - * case req @ GET -> Root / "banks" / bankId / "cards" => - * withUserAndBank(req) { (user, bank, cc) => - * for { - * (cards, callContext) <- NewStyle.function.getPhysicalCardsForBank(bank, user, obpQueryParams, Some(cc)) - * } yield JSONFactory1_3_0.createPhysicalCardsJSON(cards, user) - * } - * }}} + * Extracts both from CallContext, executes logic, returns JSON response. * - * @param req The http4s request - * @param f Business logic function that takes (User, Bank, CallContext) and returns Future[A] - * @param formats Implicit JSON formats for serialization - * @tparam A The result type (will be converted to JSON) + * @param req http4s request + * @param f Business logic: (User, Bank, CallContext) => Future[A] * @return IO[Response[IO]] with JSON body */ def withUserAndBank[A](req: Request[IO])(f: (User, Bank, CallContext) => Future[A])(implicit formats: Formats): IO[Response[IO]] = { diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 42d9b466d..878d398dd 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -20,14 +20,17 @@ import scala.language.higherKinds * 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 first - * 2. Roles authorization (if roles specified in ResourceDoc) - * 3. BANK_ID validation (if present in path) - * 4. ACCOUNT_ID validation (if present in path) - * 5. VIEW_ID validation (if present in path) - * 6. COUNTERPARTY_ID validation (if present in path) + * 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{ @@ -36,20 +39,26 @@ object ResourceDocMiddleware extends MdcLoggable{ private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) /** - * Check if ResourceDoc requires authentication based on errorResponseBodies or property + * 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 = { - // Special handling for resource-docs endpoint if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") { APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) } else { - // Standard check: roles always require an authenticated user to validate entitlements resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty) } } /** - * Create middleware that applies ResourceDoc-driven validation to standard HttpRoutes + * Create middleware that applies ResourceDoc-driven validation. + * + * @param resourceDocs Collection of ResourceDoc entries for matching + * @return Middleware that wraps HttpRoutes with validation */ def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes => Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -58,7 +67,13 @@ object ResourceDocMiddleware extends MdcLoggable{ } /** - * Validate request and route to handler if validation passes + * Validate request and route to handler if validation passes. + * + * Steps: + * 1. Build CallContext from request + * 2. Find matching ResourceDoc + * 3. Run validation chain + * 4. Route to handler with enriched CallContext */ private def validateAndRoute( req: Request[IO], @@ -80,6 +95,9 @@ object ResourceDocMiddleware extends MdcLoggable{ } yield response } + /** + * Ensure 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 @@ -88,179 +106,18 @@ object ResourceDocMiddleware extends MdcLoggable{ } /** - * Run validation chain and return enriched CallContext. - * Used by wrapEndpoint to validate and enrich CallContext before passing to endpoint. - */ - private def runValidationChain( - resourceDoc: ResourceDoc, - cc: CallContext, - pathParams: Map[String, String] - ): IO[CallContext] = { - import com.openbankproject.commons.ExecutionContext.Implicits.global - - // Step 1: Authentication - val needsAuth = needsAuthentication(resourceDoc) - logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") - - val authResult: IO[Either[Throwable, (Box[User], CallContext)]] = - if (needsAuth) { - IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.flatMap { - case Right((boxUser, optCC)) => - val updatedCC = optCC.getOrElse(cc) - boxUser match { - case Full(user) => - IO.pure(Right((boxUser, updatedCC))) - case Empty => - IO.pure(Left(new RuntimeException($AuthenticatedUserIsRequired))) - case LiftFailure(msg, _, _) => - IO.pure(Left(new RuntimeException(msg))) - } - case Left(e: APIFailureNewStyle) => - IO.pure(Left(e)) - case Left(e) => - IO.pure(Left(new RuntimeException($AuthenticatedUserIsRequired))) - } - } else { - IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap { - case Right((boxUser, Some(updatedCC))) => - IO.pure(Right((boxUser, updatedCC))) - case Right((boxUser, None)) => - IO.pure(Right((boxUser, cc))) - case Left(e) => - // For anonymous access, continue with Empty user - IO.pure(Right((Empty, cc))) - } - } - - authResult.flatMap { - case Left(error) => IO.raiseError(error) - case Right((boxUser, cc1)) => - // Step 2: Role authorization - val rolesResult: IO[Either[Throwable, CallContext]] = - resourceDoc.roles match { - case Some(roles) if roles.nonEmpty => - val shouldCheckRoles = if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") { - APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) - } else { - true - } - - if (shouldCheckRoles) { - boxUser match { - case Full(user) => - val userId = user.userId - val bankId = pathParams.get("BANK_ID").getOrElse("") - val hasRole = roles.exists { role => - val checkBankId = if (role.requiresBankId) bankId else "" - APIUtil.hasEntitlement(checkBankId, userId, role) - } - if (hasRole) IO.pure(Right(cc1)) - else IO.pure(Left(new RuntimeException(UserHasMissingRoles + roles.mkString(", ")))) - case _ => - IO.pure(Left(new RuntimeException($AuthenticatedUserIsRequired))) - } - } else { - IO.pure(Right(cc1)) - } - case _ => IO.pure(Right(cc1)) - } - - rolesResult.flatMap { - case Left(error) => IO.raiseError(error) - case Right(cc2) => - // Step 3: Bank validation - val bankResult: IO[Either[Throwable, (Option[Bank], CallContext)]] = - pathParams.get("BANK_ID") match { - case Some(bankIdStr) => - IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc2)))).attempt.flatMap { - case Right((bank, Some(updatedCC))) => - IO.pure(Right((Some(bank), updatedCC))) - case Right((bank, None)) => - IO.pure(Right((Some(bank), cc2))) - case Left(e: APIFailureNewStyle) => - IO.pure(Left(e)) - case Left(e) => - IO.pure(Left(new RuntimeException(BankNotFound + ": " + bankIdStr))) - } - case None => IO.pure(Right((None, cc2))) - } - - bankResult.flatMap { - case Left(error) => IO.raiseError(error) - case Right((bankOpt, cc3)) => - // Step 4: Account validation - val accountResult: IO[Either[Throwable, (Option[BankAccount], CallContext)]] = - (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match { - case (Some(bankIdStr), Some(accountIdStr)) => - IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3)))).attempt.flatMap { - case Right((account, Some(updatedCC))) => IO.pure(Right((Some(account), updatedCC))) - case Right((account, None)) => IO.pure(Right((Some(account), cc3))) - case Left(e: APIFailureNewStyle) => - IO.pure(Left(e)) - case Left(e) => - IO.pure(Left(new RuntimeException(BankAccountNotFound + s": bankId=$bankIdStr, accountId=$accountIdStr"))) - } - case _ => IO.pure(Right((None, cc3))) - } - - accountResult.flatMap { - case Left(error) => IO.raiseError(error) - case Right((accountOpt, cc4)) => - // Step 5: View validation - val viewResult: IO[Either[Throwable, (Option[View], CallContext)]] = - (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match { - case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) => - val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr)) - IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewIdStr), bankIdAccountId, boxUser.toOption, Some(cc4)))).attempt.flatMap { - case Right(view) => IO.pure(Right((Some(view), cc4))) - case Left(e: APIFailureNewStyle) => - IO.pure(Left(e)) - case Left(e) => - IO.pure(Left(new RuntimeException(UserNoPermissionAccessView + s": viewId=$viewIdStr"))) - } - case _ => IO.pure(Right((None, cc4))) - } - - viewResult.flatMap { - case Left(error) => IO.raiseError(error) - case Right((viewOpt, cc5)) => - // Step 6: Counterparty validation - val counterpartyResult: IO[Either[Throwable, (Option[CounterpartyTrait], CallContext)]] = - (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match { - case (Some(bankIdStr), Some(accountIdStr), Some(counterpartyIdStr)) => - IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankIdStr), AccountId(accountIdStr), counterpartyIdStr, Some(cc5)))).attempt.flatMap { - case Right((counterparty, Some(updatedCC))) => IO.pure(Right((Some(counterparty), updatedCC))) - case Right((counterparty, None)) => IO.pure(Right((Some(counterparty), cc5))) - case Left(e: APIFailureNewStyle) => - IO.pure(Left(e)) - case Left(e) => - IO.pure(Left(new RuntimeException(CounterpartyNotFound + s": counterpartyId=$counterpartyIdStr"))) - } - case _ => IO.pure(Right((None, cc5))) - } - - counterpartyResult.flatMap { - case Left(error) => IO.raiseError(error) - case Right((counterpartyOpt, finalCC)) => - // All validations passed - return enriched CallContext - val enrichedCC = finalCC.copy( - bank = bankOpt, - bankAccount = accountOpt, - view = viewOpt, - counterparty = counterpartyOpt - ) - IO.pure(enrichedCC) - } - } - } - } - } - } - } - - /** - * Run validation chain for standard HttpRoutes (returns Response). - * Used by apply() middleware for backward compatibility. + * Run validation chain for HttpRoutes and return Response. + * + * This method performs all validation steps in order: + * 1. Authentication (if required) + * 2. Role authorization (if roles specified) + * 3. Bank validation (if BANK_ID in path) + * 4. Account validation (if ACCOUNT_ID in path) + * 5. View validation (if VIEW_ID in path) + * 6. Counterparty validation (if COUNTERPARTY_ID in path) + * + * On success: Enriches CallContext with validated entities and routes to handler + * On failure: Returns error response immediately */ private def runValidationChainForRoutes( req: Request[IO], @@ -270,10 +127,10 @@ object ResourceDocMiddleware extends MdcLoggable{ routes: HttpRoutes[IO] ): IO[Response[IO]] = { - // Step 1: Authentication val needsAuth = needsAuthentication(resourceDoc) logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") + // Step 1: Authentication val authResult: IO[Either[Response[IO], (Box[User], CallContext)]] = if (needsAuth) { IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.flatMap { @@ -305,24 +162,19 @@ object ResourceDocMiddleware extends MdcLoggable{ } else { IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap { case Right((boxUser, Some(updatedCC))) => - logger.debug(s"[ResourceDocMiddleware] anonymousAccess succeeded with user: $boxUser") IO.pure(Right((boxUser, updatedCC))) case Right((boxUser, None)) => - logger.debug(s"[ResourceDocMiddleware] anonymousAccess succeeded with user: $boxUser (no updated CC)") IO.pure(Right((boxUser, cc))) case Left(e) => - // For anonymous access, we don't fail on auth errors - just continue with Empty user - // This allows endpoints without $AuthenticatedUserIsRequired to work without authentication - logger.debug(s"[ResourceDocMiddleware] anonymousAccess threw exception (ignoring for anonymous): ${e.getClass.getName}: ${e.getMessage.take(100)}") + // For anonymous endpoints, continue with Empty user even if auth fails IO.pure(Right((Empty, cc))) } } - - authResult.flatMap { + authResult.flatMap { case Left(errorResponse) => IO.pure(errorResponse) case Right((boxUser, cc1)) => - // Step 2: Role authorization - BEFORE business logic validation + // Step 2: Role authorization val rolesResult: IO[Either[Response[IO], CallContext]] = resourceDoc.roles match { case Some(roles) if roles.nonEmpty =>