@@ -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 }
0 commit comments