diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 825c3cb523..8c51d388fd 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1114,9 +1114,9 @@ featured_apis=elasticSearchWarehouseV300 # In case isn't defined default value is "false" # require_scopes_for_all_roles=false # require_scopes_for_listed_roles=CanCreateUserAuthContext,CanGetCustomer -# Scopes can also be used as an alternative to User Entitlements -# i.e. instead of asking every user to have a Role, you can give the Role(s) to a Consumer in the form of a Scope -# allow_entitlements_or_scopes=false +# Scopes can also be used as an alternative to User Entitlements on a per-endpoint basis. +# Set authMode = UserOrApplication on individual ResourceDoc instances to allow +# consumer scopes OR user entitlements for that endpoint. # --------------------------------------------------------------- # -- Just in Time Entitlements ------------------------------- diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index fc47a9413b..bac7995200 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -496,9 +496,9 @@ class Boot extends MdcLoggable { //add management apis LiftRules.statelessDispatch.append(ImporterAPI) } - + enableAPIs - + //LiftRules.statelessDispatch.append(AccountsAPI) @@ -693,8 +693,6 @@ class Boot extends MdcLoggable { LiftSession.onSessionActivate = UsernameLockedChecker.onSessionActivate _ :: LiftSession.onSessionActivate LiftSession.onSessionPassivate = UsernameLockedChecker.onSessionPassivate _ :: LiftSession.onSessionPassivate - // Sanity check for incompatible Props values for Scopes. - sanityCheckOPropertiesRegardingScopes() // export one Connector's methods as endpoints, it is just for develop APIUtil.getPropsValue("connector.name.export.as.endpoints").foreach { connectorName => // validate whether "connector.name.export.as.endpoints" have set a correct value @@ -731,17 +729,6 @@ class Boot extends MdcLoggable { } - private def sanityCheckOPropertiesRegardingScopes() = { - if (propertiesRegardingScopesAreValid()) { - throw new Exception(s"Incompatible Props values for Scopes.") - } - } - - def propertiesRegardingScopesAreValid() = { - (ApiPropsWithAlias.requireScopesForAllRoles || !getPropsValue("require_scopes_for_listed_roles").toList.map(_.split(",")).isEmpty) && - APIUtil.getPropsAsBoolValue("allow_entitlements_or_scopes", false) - } - // create Hydra client if exists active consumer but missing Hydra client def createHydraClients() = { try { diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index f2e7391663..4281431319 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -653,9 +653,11 @@ object OpenAPI31JSONFactory extends MdcLoggable { */ private def requiresAuthentication(doc: ResourceDocJson): Boolean = { doc.error_response_bodies.exists(_.contains("AuthenticatedUserIsRequired")) || + doc.error_response_bodies.exists(_.contains("ApplicationNotIdentified")) || doc.roles.nonEmpty || doc.description.toLowerCase.contains("authentication is required") || - doc.description.toLowerCase.contains("user must be logged in") + doc.description.toLowerCase.contains("user must be logged in") || + doc.description.toLowerCase.contains("application access is required") } /** diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index bb02be7955..9ddd1fba04 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1553,6 +1553,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ .filter(isPathVariable(_)) } + sealed trait EndpointAuthMode + case object UserOnly extends EndpointAuthMode + case object ApplicationOnly extends EndpointAuthMode + case object UserOrApplication extends EndpointAuthMode + case object UserAndApplication extends EndpointAuthMode + // Used to document the API calls case class ResourceDoc( partialFunction: OBPEndpoint, // PartialFunction[Req, Box[User] => Box[JsonResponse]], @@ -1576,6 +1582,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ specialInstructions: Option[String] = None, var specifiedUrl: Option[String] = None, // A derived value: Contains the called version (added at run time). See the resource doc for resource doc! createdByBankId: Option[String] = None, //we need to filter the resource Doc by BankId + authMode: EndpointAuthMode = UserOnly, // Per-endpoint auth mode: UserOnly, ApplicationOnly, UserOrApplication, UserAndApplication http4sPartialFunction: Http4sEndpoint = None // http4s endpoint handler ) { // this code block will be merged to constructor. @@ -1609,6 +1616,19 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ |$authenticationIsOptional |""" } + + // Auth-mode-aware error message adjustments + authMode match { + case ApplicationOnly | UserOrApplication => + errorResponseBodies ?+= ApplicationNotIdentified + if (authMode == ApplicationOnly) { + errorResponseBodies ?-= AuthenticatedUserIsRequired + } + case UserAndApplication => + errorResponseBodies ?+= AuthenticatedUserIsRequired + errorResponseBodies ?+= ApplicationNotIdentified + case UserOnly => // existing logic already handles this + } } val operationId = buildOperationId(implementedInApiVersion, partialFunctionName) @@ -1692,7 +1712,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ private val requestUrlPartPath: Array[String] = StringUtils.split(requestUrl, '/') - private val isNeedCheckAuth = errorResponseBodies.contains($AuthenticatedUserIsRequired) + private val AuthCheckIsRequired = errorResponseBodies.contains($AuthenticatedUserIsRequired) private val isNeedCheckRoles = _autoValidateRoles && rolesForCheck.nonEmpty private val isNeedCheckBank = errorResponseBodies.contains($BankNotFound) && requestUrlPartPath.contains("BANK_ID") private val isNeedCheckAccount = errorResponseBodies.contains($BankAccountNotFound) && @@ -1724,8 +1744,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def wrappedWithAuthCheck(obpEndpoint: OBPEndpoint): OBPEndpoint = { _isEndpointAuthCheck = true - def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = { - if (isNeedCheckAuth) authenticatedAccessFun(cc) else anonymousAccessFun(cc) + def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = authMode match { + case UserOnly | UserAndApplication => + if (AuthCheckIsRequired) authenticatedAccessFun(cc) else anonymousAccessFun(cc) + case ApplicationOnly | UserOrApplication => + applicationAccessFun(cc) } def checkObpIds(obpKeyValuePairs: List[(String, String)], callContext: Option[CallContext]): Future[Option[CallContext]] = { @@ -1747,7 +1770,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ if (isNeedCheckRoles) { val bankIdStr = bankId.map(_.value).getOrElse("") val userIdStr = user.map(_.userId).openOr("") - checkRolesFun(bankIdStr)(userIdStr, rolesForCheck, cc) + val consumerId = APIUtil.getConsumerPrimaryKey(cc) + val errorMessage = if (rolesForCheck.filter(_.requiresBankId).isEmpty) + UserHasMissingRoles + rolesForCheck.mkString(" or ") + else + UserHasMissingRoles + rolesForCheck.mkString(" or ") + s" for BankId($bankIdStr)." + Helper.booleanToFuture(errorMessage, cc = cc) { + APIUtil.handleAccessControlWithAuthMode(bankIdStr, userIdStr, consumerId, rolesForCheck, authMode) + } } else { Future.successful(Full(Unit)) } @@ -1792,10 +1822,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // reset connectorMethods { val checkerFunctions = mutable.ListBuffer[PartialFunction[_, _]]() - if (isNeedCheckAuth) { - checkerFunctions += authenticatedAccessFun - } else { - checkerFunctions += anonymousAccessFun + authMode match { + case UserOnly | UserAndApplication => + if (AuthCheckIsRequired) checkerFunctions += authenticatedAccessFun + else checkerFunctions += anonymousAccessFun + case ApplicationOnly | UserOrApplication => + checkerFunctions += applicationAccessFun } if (isNeedCheckRoles) { checkerFunctions += checkRolesFun @@ -2403,6 +2435,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // Function checks does a user specified by a parameter userId has at least one role provided by a parameter roles at a bank specified by a parameter bankId // i.e. does user has assigned at least one role from the list // when roles is empty, that means no access control, treat as pass auth check + @deprecated("Use handleAccessControlWithAuthMode instead. It uses per-endpoint EndpointAuthMode rather than global config flags.", "OBP v6.0.0") def handleAccessControlRegardingEntitlementsAndScopes(bankId: String, userId: String, consumerId: String, roles: List[ApiRole]): Boolean = { if (roles.isEmpty) { // No access control, treat as pass auth check true @@ -2443,10 +2476,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ if (ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty) { userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _)) } - // Consumer OR User has the Role - else if (getPropsAsBoolValue("allow_entitlements_or_scopes", false)) { - roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) || userHasTheRoles - } // User has the Role else { userHasTheRoles @@ -2457,6 +2486,65 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ + /** + * Per-endpoint auth mode access control. Checks virtual roles first, then matches on authMode: + * - UserOnly: user entitlements only (includes just-in-time entitlements) + * - ApplicationOnly: consumer scopes only + * - UserOrApplication: scopes OR entitlements + * - UserAndApplication: scopes AND entitlements + * Global overrides (require_scopes_for_all_roles, require_scopes_for_listed_roles) force UserAndApplication behavior. + */ + def handleAccessControlWithAuthMode(bankId: String, userId: String, consumerId: String, roles: List[ApiRole], authMode: EndpointAuthMode): Boolean = { + if (roles.isEmpty) { + true + } else { + // Check virtual roles granted by config (super_admin_user_ids, oidc_operator_user_ids) + val virtualRoles = if (isSuperAdmin(userId)) superAdminVirtualRoles + else if (isOidcOperator(userId)) oidcOperatorVirtualRoles + else List.empty + if (roles.exists(role => virtualRoles.contains(role.toString))) { + true + } else { + // Global overrides that force UserAndApplication (scopes AND entitlements) + val requireScopesForListedRoles = getPropsValue("require_scopes_for_listed_roles", "").split(",").toSet + val requireScopesForRoles = roles.map(_.toString).toSet.intersect(requireScopesForListedRoles) + val globalOverrideToUserAndApp = ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty + + def userHasTheRoles: Boolean = { + val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) + userHasTheRole || { + getPropsAsBoolValue("create_just_in_time_entitlements", false) && { + (hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) || + hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) && + roles.forall { role => + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement( + bankId, userId, role.toString, "create_just_in_time_entitlements" + ) + logger.info(s"Just in Time Entitlements: $addedEntitlement") + addedEntitlement.isDefined + } + } + } + } + + def consumerHasTheScopes: Boolean = + roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) + + if (globalOverrideToUserAndApp) { + // Global config forces both scopes AND entitlements + userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _)) + } else { + authMode match { + case UserOnly => userHasTheRoles + case ApplicationOnly => consumerHasTheScopes + case UserOrApplication => consumerHasTheScopes || userHasTheRoles + case UserAndApplication => userHasTheRoles && consumerHasTheScopes + } + } + } + } + } + // Function checks does a user specified by a parameter userId has all roles provided by a parameter roles at a bank specified by a parameter bankId // i.e. does user has assigned all roles from the list // when roles is empty, that means no access control, treat as pass auth check @@ -4531,6 +4619,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ private val anonymousAccessFun: PartialFunction[CallContext, OBPReturnType[Box[User]]] = { case x => anonymousAccess(x) } + private val applicationAccessFun: PartialFunction[CallContext, Future[(Box[User], Option[CallContext])]] = { + case x => applicationAccess(x) + } private val checkRolesFun: PartialFunction[String, (String, List[ApiRole], Option[CallContext]) => Future[Box[Unit]]] = { case x => NewStyle.function.handleEntitlementsAndScopes(x, _, _, _) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 460f2981fa..823bb1c9b5 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -28,7 +28,8 @@ object Http4sApp { * Build the base HTTP4S routes with priority-based routing */ private def baseServices: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => - code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req) + StatusPage.routes.run(req) + .orElse(code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req)) .orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) .orElse(Http4sLiftWebBridge.routes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala new file mode 100644 index 0000000000..e0049c7723 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -0,0 +1,113 @@ +package code.api.util.http4s + +import cats.effect.IO +import code.api.util.APIUtil +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.headers.{Accept, `Content-Type`} + +object StatusPage { + + private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs + + private def humanName(key: String): String = + key.stripPrefix("public_") + .stripSuffix("_url") + .replace("_", " ") + .split(" ") + .map(_.capitalize) + .mkString(" ") + + private def prefersJson(req: Request[IO]): Boolean = + req.headers.get[Accept].exists { accept => + accept.values.toList.exists { mediaRange => + mediaRange.mediaRange.satisfiedBy(MediaType.application.json) + } + } + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> Root => + if (prefersJson(req)) jsonResponse else htmlResponse + } + + private def jsonResponse: IO[Response[IO]] = { + val pairs = appDiscoveryPairs + val appDirectory = pairs.map { case (name, url) => + s""" {"name": "${humanName(name)}", "key": "$name", "url": "$url"}""" + }.mkString(",\n") + + val json = + s"""{ + | "app_directory": [ + |$appDirectory + | ], + | "discovery_endpoints": { + | "api_info": "/obp/v6.0.0/root", + | "resource_docs": "/obp/v6.0.0/resource-docs/v6.0.0/obp", + | "well_known": "/obp/v5.1.0/well-known", + | "banks": "/obp/v6.0.0/banks" + | }, + | "links": { + | "github": "https://github.com/OpenBankProject/OBP-API", + | "tesobe": "https://www.tesobe.com", + | "open_bank_project": "https://www.openbankproject.com" + | }, + | "copyright": "Copyright TESOBE GmbH 2010-2026" + |}""".stripMargin + + Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json))) + } + + private def htmlResponse: IO[Response[IO]] = { + val appDiscoveryLinks = appDiscoveryPairs.map { case (name, url) => + s"""
OBP API is a headless open source Open Banking API stack. Navigate to the Apps below to interact with the APIs or see the Discovery Endpoints.
+ | + |See also API Explorer, Portal or MCP Server above.
+ |