Light Mode

Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 9879159

Browse files
Merge remote-tracking branch 'upstream/develop' into develop
2 parents 0782569 + e9ebb7c commit 9879159

File tree

12 files changed

+333
-81
lines changed
  • obp-api/src
    • main
      • resources/props
        • sample.props.template
      • scala
        • bootstrap/liftweb
          • Boot.scala
        • code/api
          • ResourceDocs1_4_0
            • OpenAPI31JSONFactory.scala
          • util
            • APIUtil.scala
            • http4s
              • Http4sApp.scala
              • StatusPage.scala
          • v6_0_0
            • APIMethods600.scala
    • test/scala/code/api
      • v4_0_0
        • ScopesTest.scala
      • v6_0_0
        • EndpointAuthModeTest.scala
        • VerifyUserCredentialsTest.scala
  • release_notes.md
  • run_all_tests.sh

12 files changed

+333
-81
lines changed

obp-api/src/main/resources/props/sample.props.template

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,9 +1114,9 @@ featured_apis=elasticSearchWarehouseV300
11141114
# In case isn't defined default value is "false"
11151115
# require_scopes_for_all_roles=false
11161116
# require_scopes_for_listed_roles=CanCreateUserAuthContext,CanGetCustomer
1117-
# Scopes can also be used as an alternative to User Entitlements
1118-
# 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
1119-
# allow_entitlements_or_scopes=false
1117+
# Scopes can also be used as an alternative to User Entitlements on a per-endpoint basis.
1118+
# Set authMode = UserOrApplication on individual ResourceDoc instances to allow
1119+
# consumer scopes OR user entitlements for that endpoint.
11201120
# ---------------------------------------------------------------
11211121

11221122
# -- Just in Time Entitlements -------------------------------

obp-api/src/main/scala/bootstrap/liftweb/Boot.scala

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -496,9 +496,9 @@ class Boot extends MdcLoggable {
496496
//add management apis
497497
LiftRules.statelessDispatch.append(ImporterAPI)
498498
}
499-
499+
500500
enableAPIs
501-
501+
502502

503503

504504
//LiftRules.statelessDispatch.append(AccountsAPI)
@@ -693,8 +693,6 @@ class Boot extends MdcLoggable {
693693
LiftSession.onSessionActivate = UsernameLockedChecker.onSessionActivate _ :: LiftSession.onSessionActivate
694694
LiftSession.onSessionPassivate = UsernameLockedChecker.onSessionPassivate _ :: LiftSession.onSessionPassivate
695695

696-
// Sanity check for incompatible Props values for Scopes.
697-
sanityCheckOPropertiesRegardingScopes()
698696
// export one Connector's methods as endpoints, it is just for develop
699697
APIUtil.getPropsValue("connector.name.export.as.endpoints").foreach { connectorName =>
700698
// validate whether "connector.name.export.as.endpoints" have set a correct value
@@ -731,17 +729,6 @@ class Boot extends MdcLoggable {
731729

732730
}
733731

734-
private def sanityCheckOPropertiesRegardingScopes() = {
735-
if (propertiesRegardingScopesAreValid()) {
736-
throw new Exception(s"Incompatible Props values for Scopes.")
737-
}
738-
}
739-
740-
def propertiesRegardingScopesAreValid() = {
741-
(ApiPropsWithAlias.requireScopesForAllRoles || !getPropsValue("require_scopes_for_listed_roles").toList.map(_.split(",")).isEmpty) &&
742-
APIUtil.getPropsAsBoolValue("allow_entitlements_or_scopes", false)
743-
}
744-
745732
// create Hydra client if exists active consumer but missing Hydra client
746733
def createHydraClients() = {
747734
try {

obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,9 +653,11 @@ object OpenAPI31JSONFactory extends MdcLoggable {
653653
*/
654654
private def requiresAuthentication(doc: ResourceDocJson): Boolean = {
655655
doc.error_response_bodies.exists(_.contains("AuthenticatedUserIsRequired")) ||
656+
doc.error_response_bodies.exists(_.contains("ApplicationNotIdentified")) ||
656657
doc.roles.nonEmpty ||
657658
doc.description.toLowerCase.contains("authentication is required") ||
658-
doc.description.toLowerCase.contains("user must be logged in")
659+
doc.description.toLowerCase.contains("user must be logged in") ||
660+
doc.description.toLowerCase.contains("application access is required")
659661
}
660662

661663
/**

obp-api/src/main/scala/code/api/util/APIUtil.scala

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
15531553
.filter(isPathVariable(_))
15541554
}
15551555

1556+
sealed trait EndpointAuthMode
1557+
case object UserOnly extends EndpointAuthMode
1558+
case object ApplicationOnly extends EndpointAuthMode
1559+
case object UserOrApplication extends EndpointAuthMode
1560+
case object UserAndApplication extends EndpointAuthMode
1561+
15561562
// Used to document the API calls
15571563
case class ResourceDoc(
15581564
partialFunction: OBPEndpoint, // PartialFunction[Req, Box[User] => Box[JsonResponse]],
@@ -1576,6 +1582,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
15761582
specialInstructions: Option[String] = None,
15771583
var specifiedUrl: Option[String] = None, // A derived value: Contains the called version (added at run time). See the resource doc for resource doc!
15781584
createdByBankId: Option[String] = None, //we need to filter the resource Doc by BankId
1585+
authMode: EndpointAuthMode = UserOnly, // Per-endpoint auth mode: UserOnly, ApplicationOnly, UserOrApplication, UserAndApplication
15791586
http4sPartialFunction: Http4sEndpoint = None // http4s endpoint handler
15801587
) {
15811588
// this code block will be merged to constructor.
@@ -1609,6 +1616,19 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
16091616
|$authenticationIsOptional
16101617
|"""
16111618
}
1619+
1620+
// Auth-mode-aware error message adjustments
1621+
authMode match {
1622+
case ApplicationOnly | UserOrApplication =>
1623+
errorResponseBodies ?+= ApplicationNotIdentified
1624+
if (authMode == ApplicationOnly) {
1625+
errorResponseBodies ?-= AuthenticatedUserIsRequired
1626+
}
1627+
case UserAndApplication =>
1628+
errorResponseBodies ?+= AuthenticatedUserIsRequired
1629+
errorResponseBodies ?+= ApplicationNotIdentified
1630+
case UserOnly => // existing logic already handles this
1631+
}
16121632
}
16131633

16141634
val operationId = buildOperationId(implementedInApiVersion, partialFunctionName)
@@ -1692,7 +1712,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
16921712

16931713
private val requestUrlPartPath: Array[String] = StringUtils.split(requestUrl, '/')
16941714

1695-
private val isNeedCheckAuth = errorResponseBodies.contains($AuthenticatedUserIsRequired)
1715+
private val AuthCheckIsRequired = errorResponseBodies.contains($AuthenticatedUserIsRequired)
16961716
private val isNeedCheckRoles = _autoValidateRoles && rolesForCheck.nonEmpty
16971717
private val isNeedCheckBank = errorResponseBodies.contains($BankNotFound) && requestUrlPartPath.contains("BANK_ID")
16981718
private val isNeedCheckAccount = errorResponseBodies.contains($BankAccountNotFound) &&
@@ -1724,8 +1744,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
17241744
def wrappedWithAuthCheck(obpEndpoint: OBPEndpoint): OBPEndpoint = {
17251745
_isEndpointAuthCheck = true
17261746

1727-
def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = {
1728-
if (isNeedCheckAuth) authenticatedAccessFun(cc) else anonymousAccessFun(cc)
1747+
def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = authMode match {
1748+
case UserOnly | UserAndApplication =>
1749+
if (AuthCheckIsRequired) authenticatedAccessFun(cc) else anonymousAccessFun(cc)
1750+
case ApplicationOnly | UserOrApplication =>
1751+
applicationAccessFun(cc)
17291752
}
17301753

17311754
def checkObpIds(obpKeyValuePairs: List[(String, String)], callContext: Option[CallContext]): Future[Option[CallContext]] = {
@@ -1747,7 +1770,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
17471770
if (isNeedCheckRoles) {
17481771
val bankIdStr = bankId.map(_.value).getOrElse("")
17491772
val userIdStr = user.map(_.userId).openOr("")
1750-
checkRolesFun(bankIdStr)(userIdStr, rolesForCheck, cc)
1773+
val consumerId = APIUtil.getConsumerPrimaryKey(cc)
1774+
val errorMessage = if (rolesForCheck.filter(_.requiresBankId).isEmpty)
1775+
UserHasMissingRoles + rolesForCheck.mkString(" or ")
1776+
else
1777+
UserHasMissingRoles + rolesForCheck.mkString(" or ") + s" for BankId($bankIdStr)."
1778+
Helper.booleanToFuture(errorMessage, cc = cc) {
1779+
APIUtil.handleAccessControlWithAuthMode(bankIdStr, userIdStr, consumerId, rolesForCheck, authMode)
1780+
}
17511781
} else {
17521782
Future.successful(Full(Unit))
17531783
}
@@ -1792,10 +1822,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
17921822
// reset connectorMethods
17931823
{
17941824
val checkerFunctions = mutable.ListBuffer[PartialFunction[_, _]]()
1795-
if (isNeedCheckAuth) {
1796-
checkerFunctions += authenticatedAccessFun
1797-
} else {
1798-
checkerFunctions += anonymousAccessFun
1825+
authMode match {
1826+
case UserOnly | UserAndApplication =>
1827+
if (AuthCheckIsRequired) checkerFunctions += authenticatedAccessFun
1828+
else checkerFunctions += anonymousAccessFun
1829+
case ApplicationOnly | UserOrApplication =>
1830+
checkerFunctions += applicationAccessFun
17991831
}
18001832
if (isNeedCheckRoles) {
18011833
checkerFunctions += checkRolesFun
@@ -2403,6 +2435,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
24032435
// 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
24042436
// i.e. does user has assigned at least one role from the list
24052437
// when roles is empty, that means no access control, treat as pass auth check
2438+
@deprecated("Use handleAccessControlWithAuthMode instead. It uses per-endpoint EndpointAuthMode rather than global config flags.", "OBP v6.0.0")
24062439
def handleAccessControlRegardingEntitlementsAndScopes(bankId: String, userId: String, consumerId: String, roles: List[ApiRole]): Boolean = {
24072440
if (roles.isEmpty) { // No access control, treat as pass auth check
24082441
true
@@ -2443,10 +2476,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
24432476
if (ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty) {
24442477
userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _))
24452478
}
2446-
// Consumer OR User has the Role
2447-
else if (getPropsAsBoolValue("allow_entitlements_or_scopes", false)) {
2448-
roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) || userHasTheRoles
2449-
}
24502479
// User has the Role
24512480
else {
24522481
userHasTheRoles
@@ -2457,6 +2486,65 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
24572486

24582487

24592488

2489+
/**
2490+
* Per-endpoint auth mode access control. Checks virtual roles first, then matches on authMode:
2491+
* - UserOnly: user entitlements only (includes just-in-time entitlements)
2492+
* - ApplicationOnly: consumer scopes only
2493+
* - UserOrApplication: scopes OR entitlements
2494+
* - UserAndApplication: scopes AND entitlements
2495+
* Global overrides (require_scopes_for_all_roles, require_scopes_for_listed_roles) force UserAndApplication behavior.
2496+
*/
2497+
def handleAccessControlWithAuthMode(bankId: String, userId: String, consumerId: String, roles: List[ApiRole], authMode: EndpointAuthMode): Boolean = {
2498+
if (roles.isEmpty) {
2499+
true
2500+
} else {
2501+
// Check virtual roles granted by config (super_admin_user_ids, oidc_operator_user_ids)
2502+
val virtualRoles = if (isSuperAdmin(userId)) superAdminVirtualRoles
2503+
else if (isOidcOperator(userId)) oidcOperatorVirtualRoles
2504+
else List.empty
2505+
if (roles.exists(role => virtualRoles.contains(role.toString))) {
2506+
true
2507+
} else {
2508+
// Global overrides that force UserAndApplication (scopes AND entitlements)
2509+
val requireScopesForListedRoles = getPropsValue("require_scopes_for_listed_roles", "").split(",").toSet
2510+
val requireScopesForRoles = roles.map(_.toString).toSet.intersect(requireScopesForListedRoles)
2511+
val globalOverrideToUserAndApp = ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty
2512+
2513+
def userHasTheRoles: Boolean = {
2514+
val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _))
2515+
userHasTheRole || {
2516+
getPropsAsBoolValue("create_just_in_time_entitlements", false) && {
2517+
(hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) ||
2518+
hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) &&
2519+
roles.forall { role =>
2520+
val addedEntitlement = Entitlement.entitlement.vend.addEntitlement(
2521+
bankId, userId, role.toString, "create_just_in_time_entitlements"
2522+
)
2523+
logger.info(s"Just in Time Entitlements: $addedEntitlement")
2524+
addedEntitlement.isDefined
2525+
}
2526+
}
2527+
}
2528+
}
2529+
2530+
def consumerHasTheScopes: Boolean =
2531+
roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role))
2532+
2533+
if (globalOverrideToUserAndApp) {
2534+
// Global config forces both scopes AND entitlements
2535+
userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _))
2536+
} else {
2537+
authMode match {
2538+
case UserOnly => userHasTheRoles
2539+
case ApplicationOnly => consumerHasTheScopes
2540+
case UserOrApplication => consumerHasTheScopes || userHasTheRoles
2541+
case UserAndApplication => userHasTheRoles && consumerHasTheScopes
2542+
}
2543+
}
2544+
}
2545+
}
2546+
}
2547+
24602548
// 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
24612549
// i.e. does user has assigned all roles from the list
24622550
// when roles is empty, that means no access control, treat as pass auth check
@@ -4531,6 +4619,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
45314619
private val anonymousAccessFun: PartialFunction[CallContext, OBPReturnType[Box[User]]] = {
45324620
case x => anonymousAccess(x)
45334621
}
4622+
private val applicationAccessFun: PartialFunction[CallContext, Future[(Box[User], Option[CallContext])]] = {
4623+
case x => applicationAccess(x)
4624+
}
45344625
private val checkRolesFun: PartialFunction[String, (String, List[ApiRole], Option[CallContext]) => Future[Box[Unit]]] = {
45354626
case x => NewStyle.function.handleEntitlementsAndScopes(x, _, _, _)
45364627
}

obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ object Http4sApp {
2828
* Build the base HTTP4S routes with priority-based routing
2929
*/
3030
private def baseServices: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] =>
31-
code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req)
31+
StatusPage.routes.run(req)
32+
.orElse(code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req))
3233
.orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req))
3334
.orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req))
3435
.orElse(Http4sLiftWebBridge.routes.run(req))

obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package code.api.util.http4s
2+
3+
import cats.effect.IO
4+
import code.api.util.APIUtil
5+
import org.http4s._
6+
import org.http4s.dsl.io._
7+
import org.http4s.headers.{Accept, `Content-Type`}
8+
9+
object StatusPage {
10+
11+
private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs
12+
13+
private def humanName(key: String): String =
14+
key.stripPrefix("public_")
15+
.stripSuffix("_url")
16+
.replace("_", " ")
17+
.split(" ")
18+
.map(_.capitalize)
19+
.mkString(" ")
20+
21+
private def prefersJson(req: Request[IO]): Boolean =
22+
req.headers.get[Accept].exists { accept =>
23+
accept.values.toList.exists { mediaRange =>
24+
mediaRange.mediaRange.satisfiedBy(MediaType.application.json)
25+
}
26+
}
27+
28+
val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
29+
case req @ GET -> Root =>
30+
if (prefersJson(req)) jsonResponse else htmlResponse
31+
}
32+
33+
private def jsonResponse: IO[Response[IO]] = {
34+
val pairs = appDiscoveryPairs
35+
val appDirectory = pairs.map { case (name, url) =>
36+
s""" {"name": "${humanName(name)}", "key": "$name", "url": "$url"}"""
37+
}.mkString(",\n")
38+
39+
val json =
40+
s"""{
41+
| "app_directory": [
42+
|$appDirectory
43+
| ],
44+
| "discovery_endpoints": {
45+
| "api_info": "/obp/v6.0.0/root",
46+
| "resource_docs": "/obp/v6.0.0/resource-docs/v6.0.0/obp",
47+
| "well_known": "/obp/v5.1.0/well-known",
48+
| "banks": "/obp/v6.0.0/banks"
49+
| },
50+
| "links": {
51+
| "github": "https://github.com/OpenBankProject/OBP-API",
52+
| "tesobe": "https://www.tesobe.com",
53+
| "open_bank_project": "https://www.openbankproject.com"
54+
| },
55+
| "copyright": "Copyright TESOBE GmbH 2010-2026"
56+
|}""".stripMargin
57+
58+
Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json)))
59+
}
60+
61+
private def htmlResponse: IO[Response[IO]] = {
62+
val appDiscoveryLinks = appDiscoveryPairs.map { case (name, url) =>
63+
s"""
  • ${humanName(name)} ($name)
  • """
    64+
    }.mkString("\n")
    65+
    66+
    val html =
    67+
    s"""
    68+
    |
    69+
    |
    70+
    | OBP API - Status Page
    71+
    |
    79+
    |
    80+
    |
    81+
    |

    Welcome to the OBP API technical discovery page

    82+
    |

    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.

    83+
    |
    84+
    |

    App Directory

    85+
    |
      86+
      |$appDiscoveryLinks
      87+
      |
      88+
      |
      89+
      |

      Discovery Endpoints

      90+
      |

      See also API Explorer, Portal or MCP Server above.

      91+
      |
        92+
        93+
        94+
        95+
        96+
        |
        97+
        |
        98+
        |

        Links

        99+
        |
          100+
          101+
          102+
          103+
          |
          104+
          |
          105+
          |
          106+
          | Copyright TESOBE GmbH 2010-2026
          107+
          |
          108+
          |
          109+
          |""".stripMargin
          110+
          111+
          Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html)))
          112+
          }
          113+
          }

          0 commit comments

          Comments
          (0)