Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------
Expand Down
17 changes: 2 additions & 15 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -496,9 +496,9 @@ class Boot extends MdcLoggable {
//add management apis
LiftRules.statelessDispatch.append(ImporterAPI)
}

enableAPIs



//LiftRules.statelessDispatch.append(AccountsAPI)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

/**
Expand Down
115 changes: 103 additions & 12 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,12 @@
.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]],
Expand All @@ -1576,6 +1582,7 @@
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.
Expand Down Expand Up @@ -1609,6 +1616,19 @@
|$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)
Expand Down Expand Up @@ -1692,7 +1712,7 @@

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) &&
Expand Down Expand Up @@ -1724,8 +1744,11 @@
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]] = {
Expand All @@ -1747,7 +1770,14 @@
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))
}
Expand Down Expand Up @@ -1792,10 +1822,12 @@
// 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
Expand Down Expand Up @@ -2403,6 +2435,7 @@
// 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
Expand Down Expand Up @@ -2443,10 +2476,6 @@
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
Expand All @@ -2457,6 +2486,65 @@



/**
* 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 = {

Check failure on line 2497 in obp-api/src/main/scala/code/api/util/APIUtil.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 30 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZy8_l-Zm1QRlwQ7Cv8-&open=AZy8_l-Zm1QRlwQ7Cv8-&pullRequest=2720
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
Expand Down Expand Up @@ -4531,6 +4619,9 @@
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, _, _, _)
}
Expand Down
3 changes: 2 additions & 1 deletion obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
113 changes: 113 additions & 0 deletions obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala
Original file line number Diff line number Diff line change
@@ -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""" <li><a href="$url">${humanName(name)}</a> <small>($name)</small></li>"""
}.mkString("\n")

val html =
s"""<!DOCTYPE html>
|<html>
|<head>
| <title>OBP API - Status Page</title>
| <style>
| body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
| h1 { color: #333; }
| h2 { color: #555; margin-top: 30px; }
| ul { line-height: 2; }
| a { color: #0066cc; }
| small { color: #999; }
| </style>
|</head>
|<body>
| <h1>Welcome to the OBP API technical discovery page</h1>
| <p>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.</p>
|
| <h2>App Directory</h2>
| <ul>
|$appDiscoveryLinks
| </ul>
|
| <h2>Discovery Endpoints</h2>
|<p>See also API Explorer, Portal or MCP Server above.</p>
| <ul>
| <li><a href="/obp/v6.0.0/root">API Info</a></li>
| <li><a href="/obp/v6.0.0/resource-docs/v6.0.0/obp">API Documentation</a></li>
| <li><a href="/obp/v5.1.0/well-known">Well Known URIs</a></li>
| <li><a href="/obp/v6.0.0/banks">Banks</a></li>
| </ul>
|
| <h2>Links</h2>
| <ul>
| <li><a href="https://github.com/OpenBankProject/OBP-API">OBP-API on GitHub</a></li>
| <li><a href="https://www.tesobe.com">TESOBE</a></li>
| <li><a href="https://www.openbankproject.com">Open Bank Project</a></li>
| </ul>
|
| <footer style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; color: #999; font-size: 0.9em;">
| Copyright TESOBE GmbH 2010-2026
| </footer>
|</body>
|</html>""".stripMargin

Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html)))
}
}
Loading