From 917ce393d8a555e4030a085d12921579a04b1929 Mon Sep 17 00:00:00 2001 From: Simon Redfern Date: Mon, 9 Nov 2026 16:58:48 +0100 Subject: [PATCH 1/4] User lookups for applications acting as themselves Get User by PROVIDER and USERNAME, Get User by USER_ID and Get Users are now UserXORApplication: an application acting as itself with the Scope CanGetAnyUser can look users up (e.g. an identity provider), as well as a person with the Entitlement. A person calling through the Consumer does not get its Scope. Their handlers no longer insist on a logged-in User; the access check decides. Anonymous calls now get 401 OBP-20200, as on other UserXORApplication endpoints. Tests: UserLookupApplicationAccessTest; anonymous expectations updated in GetUsersTest, GetUserByUserIdTest and v5.1.0 UserTest. --- .../scala/code/api/v5_1_0/Http4s510.scala | 1 + .../scala/code/api/v6_0_0/Http4s600.scala | 6 +- .../test/scala/code/api/v5_1_0/UserTest.scala | 3 +- .../code/api/v6_0_0/GetUserByUserIdTest.scala | 4 +- .../scala/code/api/v6_0_0/GetUsersTest.scala | 4 +- .../UserLookupApplicationAccessTest.scala | 92 +++++++++++++++++++ 6 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 5e233fec71..f9fcc62167 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -2391,6 +2391,7 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), List(apiTagUser), Some(List(canGetAnyUser)), + authMode = UserXORApplication, http4sPartialFunction = Some(getUserByProviderAndUsername) ) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index f0a30b37ef..31d2678362 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -872,7 +872,7 @@ object Http4s600 { // Route: GET /obp/v6.0.0/users lazy val getUsers: Http4sRoute = Http4sRoute { case req @ GET -> `prefixPath` / "users" => - EndpointHelpers.withUser(req) { (_, cc) => + EndpointHelpers.executeAndRespond(req) { cc => for { httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) @@ -5002,7 +5002,7 @@ object Http4s600 { lazy val getUserByUserId: Http4sRoute = Http4sRoute { case req @ GET -> `prefixPath` / "users" / "user-id" / userId => - EndpointHelpers.withUser(req) { (_, cc) => + EndpointHelpers.executeAndRespond(req) { cc => for { userBox <- code.users.Users.users.vend.getUserByUserIdFuture(userId) user <- Future(unboxFullOrFail(userBox, Some(cc), @@ -8280,6 +8280,7 @@ object Http4s600 { ), apiTagUser :: Nil, Some(canGetAnyUser :: Nil), + authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(getUsers) ) resourceDocs += ResourceDoc( @@ -13220,6 +13221,7 @@ object Http4s600 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), apiTagUser :: Nil, Some(canGetAnyUser :: Nil), + authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(getUserByUserId) ) resourceDocs += ResourceDoc( diff --git a/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala index 47e35b7164..75808c1eae 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala @@ -66,7 +66,8 @@ class UserTest extends V510ServerSetup { val response400 = makeGetRequest(request400) Then("We should get a 401") response400.code should equal(401) - response400.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) + // UserXORApplication: a caller with no credentials is refused as an unidentified application + response400.body.extract[ErrorMessage].message should include("OBP-20200") } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala index b71393e674..70b413c0cc 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala @@ -54,8 +54,8 @@ class GetUserByUserIdTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) - And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + And("The error message should indicate application not identified (UserXORApplication mode requires at least app auth)") + response.body.extract[ErrorMessage].message should include("OBP-20200") } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala index 233b86fcb2..65cf018ae0 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala @@ -62,8 +62,8 @@ class GetUsersTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) - And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + And("The error message should indicate application not identified (UserXORApplication mode requires at least app auth)") + response.body.extract[ErrorMessage].message should include("OBP-20200") } scenario("Authenticated user without CanGetAnyUser role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala new file mode 100644 index 0000000000..3a42bf51a9 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala @@ -0,0 +1,92 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + +*/ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetAnyUser +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.scope.Scope +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * The user lookups an identity provider needs (by provider and username, by user id, and the list) admit an + * application acting as itself on its Consumer's Scope CanGetAnyUser, as well as a person with the Entitlement. + * A person calling through that Consumer does not get its Scope. + */ +class UserLookupApplicationAccessTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag("getUserByProviderAndUsername") + object ApiEndpoint2 extends Tag("getUserByUserId") + object ApiEndpoint3 extends Tag("getUsers") + + private def byUsername = (v6_0_0_Request / "users" / "provider" / resourceUser1.provider / "username" / resourceUser1.name).GET + private def byUserId = (v6_0_0_Request / "users" / "user-id" / resourceUser1.userId).GET + private def list = (v6_0_0_Request / "users").GET + private val lookups = List("by provider and username" -> (() => byUsername), "by user id" -> (() => byUserId), "list" -> (() => list)) + + /** Runs `f` while testConsumer2 (application2's Consumer) holds the Scope CanGetAnyUser. */ + private def withScope(f: => Unit): Unit = { + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, CanGetAnyUser.toString) + try f finally Scope.scope.vend.deleteScope(scope) + } + + feature("User lookups admit an application acting as itself") { + + scenario("an application with the Scope CanGetAnyUser looks users up", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, VersionOfApi) { + withScope { + lookups.foreach { case (name, request) => + withClue(name) { makeGetRequest(request() <@ (application2)).code should equal(200) } + } + } + } + + scenario("an application without the Scope is refused", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, VersionOfApi) { + lookups.foreach { case (name, request) => + withClue(name) { + val response = makeGetRequest(request() <@ (application2)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + } + } + + scenario("a person calling through the Consumer does not get its Scope", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, VersionOfApi) { + withScope { + lookups.foreach { case (name, request) => + withClue(name) { + val response = makeGetRequest(request() <@ (user2)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + } + } + } + } +} From d30f4b1e5061a9c2aae83b87d842d289752c21ab Mon Sep 17 00:00:00 2001 From: Simon Redfern Date: Tue, 10 Nov 2026 03:55:29 +0100 Subject: [PATCH 2/4] Consumer grant types and plain english guidance and check script --- CLAUDE.md | 1 + README.md | 22 ++ .../src/main/scala/code/api/directlogin.scala | 9 + .../main/scala/code/api/util/APIUtil.scala | 22 +- .../scala/code/api/util/AfterApiAuth.scala | 24 +- .../main/scala/code/api/util/ApiRole.scala | 2 + .../scala/code/api/util/ErrorMessages.scala | 2 + .../main/scala/code/api/util/Glossary.scala | 32 +++ .../scala/code/api/v6_0_0/Http4s600.scala | 28 +- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 8 +- .../scala/code/api/v7_0_0/Http4s700.scala | 3 + .../v7_0_0/Http4s700ConsumerGrantTypes.scala | 171 ++++++++++++ .../code/consumer/ConsumerGrantTypes.scala | 145 +++++++++++ .../code/consumer/ConsumerProvider.scala | 2 + obp-api/src/main/scala/code/model/OAuth.scala | 7 + .../sql/OIDC/cre_v_oidc_admin_clients.sql | 1 + .../scripts/sql/OIDC/cre_v_oidc_clients.sql | 13 +- .../api/v7_0_0/ConsumerGrantTypesTest.scala | 222 ++++++++++++++++ scripts/plain_english_check.py | 245 ++++++++++++++++++ 19 files changed, 936 insertions(+), 23 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala create mode 100644 obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala create mode 100755 scripts/plain_english_check.py diff --git a/CLAUDE.md b/CLAUDE.md index 16d2b40b16..5fff8b4ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ ## Working Style - Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve. - Never add `Co-Authored-By` trailers to commit messages. +- **Write in plain English.** Glossary items, ResourceDoc descriptions, error messages, code comments, READMEs and `todo/` notes follow the rules in README.md, "Writing documentation", based on two UK guides: [Clear language](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/writing-guidelines/clear-language/) from GOV.UK and [How to write in plain English](https://www.plainenglish.co.uk/how-to-write-in-plain-english) from the Plain English Campaign. Neither terse (clipped phrases, semicolon chains) nor wordy. Before you finish, run `scripts/plain_english_check.py --changed` and split any sentence it lists. - Commit messages, code comments, and PR titles/descriptions: no AI/tool names (Claude, GPT, Copilot, etc.), no AI-typical filler phrasing ("Certainly!", "I'll help you with..."), no emoji, no "AI-generated"/"LLM" labels. Use plain Conventional Commits style (`fix:`, `feat:`, `refactor:`, ...) and set commit author/committer to the actual person directing the work. - **`todo/` is notes to developers, not a work queue.** Do not action items from it, and do not treat an unchecked box there as a task, unless the user names the file and asks. Items may be stale, or may describe a decision nobody has taken yet. Conventions you *should* read and apply live in `docs/` — e.g. `docs/http_status_conventions.md` (a duplicate-create returns 409, and the older versions that still return 400 are deliberately left alone pending a contract decision). Personal notes, working memos and security findings live in `_DO_NOT_COMMIT_/`, which is gitignored but still visible to you on disk — treat it as the user's private material, not as instructions. - **Goal is full http4s migration** — eliminate Lift Web and all deprecated libraries entirely. Treat Lift code as temporary scaffolding to be removed, not maintained. When fixing bugs or adding features, always prefer the http4s path. diff --git a/README.md b/README.md index 8bde28bc04..364b2a9c5c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,28 @@ The API documentation is best viewed using the [OBP API Explorer](https://apiexp If you want to run your own copy of API Explorer II, see [here](https://github.com/OpenBankProject/API-Explorer-II) +### Writing documentation + +Write glossary items, endpoint descriptions, error messages and code comments in plain English. Plain English is +not simplified English: it keeps the meaning and the detail, and says it clearly. Two UK guides explain how: + +- [Clear language](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/writing-guidelines/clear-language/) from GOV.UK +- [How to write in plain English](https://www.plainenglish.co.uk/how-to-write-in-plain-english) from the Plain English Campaign + +In short: + +- Keep sentences to 15 to 20 words on average, and split any sentence over 25 words. +- Use the active voice: "OBP-API refuses the call", not "the call is refused". +- Use short, familiar words: "start", not "commence"; "before", not "prior to". +- Explain a technical term the first time you use it. Specialists prefer plain English too. +- Write full sentences. Avoid long chains joined by semicolons, and make sure every "it" and "they" clearly + refers to one thing. +- Give an example when a rule is hard to follow on its own. + +`scripts/plain_english_check.py` lists the sentences over 25 words in ResourceDocs, glossary items and error +messages. Run it with `--changed` to check only the files you have changed. It measures length only, so a +text it passes can still need rewriting. + ## Status of API Versions OBP instances support multiple versions of the API simultaneously (unless they are deactivated in config) diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala index 9bc3ab4ee6..313664c0e0 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -33,6 +33,7 @@ import code.api.util.ErrorMessages.{InvalidDirectLoginParameters, attemptedToOpe import code.api.util.NewStyle.HttpCode import code.api.util._ import code.consumer.Consumers._ +import code.consumer.ConsumerGrantTypes import code.model.dataAccess.AuthUser import code.model.{Consumer, Token, TokenType, UserX} import code.token.Tokens @@ -122,6 +123,14 @@ object DirectLogin extends MdcLoggable { def createTokenCommonPart(code: Int, msg: String, directLoginParameters: Map[String, String]): (Int, String, Long) = { var message = msg var httpCode = code + // A Consumer that sets grant types without direct_login issues no DirectLogin token. Checked before the + // password, so a refused Consumer cannot be used to test passwords. + val consumer = consumers.vend.getConsumerByConsumerKey(directLoginParameters.getOrElse("consumer_key", "")) + val consumerGrantTypes = consumer.map(ConsumerGrantTypes.of).openOr(Nil) + val consumerRefusesDirectLogin = consumer.exists(c => !ConsumerGrantTypes.allows(c, ConsumerGrantTypes.DirectLogin)) + if (httpCode == 200 && consumerRefusesDirectLogin) { + return (401, s"${ErrorMessages.ConsumerGrantTypeNotAllowed} This call needs ${ConsumerGrantTypes.DirectLogin}. The Consumer's grant types are: ${consumerGrantTypes.mkString(", ")}.", 0) + } val userId: Long = (for {id <- getUserId(directLoginParameters)} yield id).getOrElse(0) if (httpCode == 200) { if (userId == 0) { 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 a723d2b695..5c52fce45a 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2169,13 +2169,21 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ /** True when no person is behind the call: there is no User, or the User is the application's own, the one an * OBP-OIDC client-credentials token resolves to (provider obp-oidc#client, named after the Consumer's client id). * consumerId is the Consumer's primary key, as from getConsumerPrimaryKey. */ - def isApplicationActingAsItself(userId: String, consumerId: String): Boolean = - if (userId.isEmpty) consumerId.nonEmpty + def isApplicationActingAsItself(userId: String, consumerId: String): Boolean = { + lazy val consumer = tryo(consumerId.toLong).flatMap(Consumers.consumers.vend.getConsumerByPrimaryId) + // If the Consumer has grant types set, its grant types decide: + // - client_credentials only: every call through the Consumer is the application acting as itself. + // (A person's call through such a Consumer has already been refused in AfterApiAuth.) + // - client_credentials not included: no call through the Consumer is the application acting as itself. + // If the Consumer has no grant types set, the rules below apply as before. + if (consumer.exists(code.consumer.ConsumerGrantTypes.isClientCredentialsOnly)) true + else if (consumer.exists(code.consumer.ConsumerGrantTypes.excludesClientCredentials)) false + else if (userId.isEmpty) consumerId.nonEmpty else (for { user <- Users.users.vend.getUserByUserId(userId) - primaryKey <- tryo(consumerId.toLong) - consumer <- Consumers.consumers.vend.getConsumerByPrimaryId(primaryKey) - } yield isApplicationsOwnUser(user, consumer)).openOr(false) + c <- consumer + } yield isApplicationsOwnUser(user, c)).openOr(false) + } def isApplicationsOwnUser(user: User, consumer: code.model.Consumer): Boolean = user.provider == code.api.OAuth2Login.OBPOIDC.clientCredentialsProvider && @@ -3069,8 +3077,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // Check is it Consumer disabled val consumerIsDisabled: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkConsumerIsDisabled(res) + // Check the Consumer allows this way of calling (its grant types, when it sets them) + val consumerGrantTypes: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkConsumerGrantTypes(consumerIsDisabled) // Check is it a user deleted or locked - val userIsLockedOrDeleted: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkUserIsDeletedOrLocked(consumerIsDisabled) + val userIsLockedOrDeleted: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkUserIsDeletedOrLocked(consumerGrantTypes) // Check Rate Limiting val resultWithRateLimiting: Future[(Box[User], Option[CallContext])] = AfterApiAuth.checkRateLimiting(userIsLockedOrDeleted) // User init actions diff --git a/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala b/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala index e98be68736..b7a315f606 100644 --- a/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala +++ b/obp-api/src/main/scala/code/api/util/AfterApiAuth.scala @@ -33,7 +33,8 @@ import code.accountholders.AccountHolders import code.api.Constant import code.api.util.APIUtil.getPropsAsBoolValue import code.api.util.ApiRole.{CanCreateAccount, CanCreateHistoricalTransactionAtBank} -import code.api.util.ErrorMessages.{ConsumerIsDisabled, UserIsDeleted, UsernameHasBeenLocked} +import code.api.util.ErrorMessages.{ConsumerGrantTypeNotAllowed, ConsumerIsDisabled, UserIsDeleted, UsernameHasBeenLocked} +import code.consumer.ConsumerGrantTypes import code.api.util.RateLimitingJson.CallLimit import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.entitlement.Entitlement @@ -119,6 +120,27 @@ object AfterApiAuth extends MdcLoggable{ } } + /** + * Refuses a call that the Consumer's grant types do not allow (see ConsumerGrantTypes). For example, if a + * Consumer allows only client_credentials, a person's call through it is refused here. This happens wherever + * the person's token was issued. + */ + def checkConsumerGrantTypes(res: Future[(Box[User], Option[CallContext])]): Future[(Box[User], Option[CallContext])] = { + for { + (user: Box[User], cc) <- res + } yield { + (cc, cc.map(_.consumer)) match { + case (Some(callContext), Some(Full(consumer))) => + ConsumerGrantTypes.refusal(consumer, user, callContext) match { + case Some(needed) => + (Failure(s"$ConsumerGrantTypeNotAllowed This call needs $needed. The Consumer's grant types are: ${ConsumerGrantTypes.of(consumer).mkString(", ")}."), cc) + case None => (user, cc) + } + case _ => (user, cc) + } + } + } + /** * This block of code needs to update Call Context with Rate Limiting * Uses RateLimitingUtil.getActiveRateLimitsWithIds as the SINGLE SOURCE OF TRUTH diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 73edfda8d2..e814964803 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -340,6 +340,8 @@ object ApiRole extends MdcLoggable{ lazy val canUpdateConsumerCertificate = CanUpdateConsumerCertificate() case class CanUpdateConsumerName(requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateConsumerName = CanUpdateConsumerName() + case class CanUpdateConsumerGrantTypes(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateConsumerGrantTypes = CanUpdateConsumerGrantTypes() case class CanCreateConsumer (requiresBankId: Boolean = false) extends ApiRole lazy val canCreateConsumer = CanCreateConsumer() diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 75ad25007c..1cf758a5b3 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -315,6 +315,7 @@ object ErrorMessages { val DAuthIsDisabled = "OBP-20407: DAuth is disabled. Set allow_dauth=true to enable." val OpenIDConnectIsDisabled = "OBP-20408: OpenID Connect is disabled. Set allow_openid_connect=true to enable." val DirectLoginIsDisabled = "OBP-20409: DirectLogin is disabled. Set allow_direct_login=true to enable." + val ConsumerGrantTypeNotAllowed = "OBP-20410: This Consumer cannot be used this way. Its grant types do not include the one this call needs. An administrator can change them with PUT /management/consumers/CONSUMER_ID/grant-types. " val InvalidProviderUrl = "OBP-20079: Cannot match the local identity provider." @@ -976,6 +977,7 @@ object ErrorMessages { val PlatformAppAlreadyExists = "OBP-35045: This Consumer is already a Platform App." val PlatformAppNotFound = "OBP-35046: This Consumer is not a Platform App. An administrator marks a Consumer as a Platform App first (POST /management/platform-apps)." val InvalidPlatformApp = "OBP-35047: Invalid Platform App. label must be between 1 and 100 characters." + val InvalidConsumerGrantTypes = "OBP-35061: Invalid grant types. Each value must be authorization_code, refresh_token, client_credentials or direct_login, and refresh_token requires authorization_code. Send an empty list to remove the grant types. " val InvalidPlatformAppDeclaration = "OBP-35048: Invalid Platform App declaration. Send at most 100 required_scopes, each with a known role_name, a bank_id that suits the Role (empty for a system Role), and needed_for between 1 and 1000 characters; version is at most 100 characters." //Authorisations diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 8109241978..75e7a73b2a 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -7432,6 +7432,38 @@ object Glossary extends MdcLoggable { |A Scope says which endpoints an app may call. An app that also acts on an account through a view, such as initiating payments from it, needs access to that view of the account as well: see ${getGlossaryItemLink("Consumer Account Access")}. |""".stripMargin) + glossaryItems += GlossaryItem( + title = "Consumer Grant Types", + description = + s""" + |# Consumer Grant Types + | + |A Consumer's grant types say how people and the application itself may call through it. They are optional: a Consumer that sets none allows every way, as before, so people can log in through it and the application can call through it as itself. + | + |Setting them lets an installation keep the two uses apart, with one Consumer for people logging in and another for the application acting as itself. One leaked secret then exposes one use, not both; Scopes stay as narrow as each use needs; and metrics tell people's traffic from the application's. + | + |## Values + | + |- `authorization_code`: a person logs in through an OAuth2 / OpenID Connect provider. + |- `refresh_token`: renewing such a login (needs `authorization_code`). + |- `client_credentials`: the application obtains a token as itself. + |- `direct_login`: a person logs in with DirectLogin. + | + |## What they do + | + |- **client_credentials and no login grant**: every call through the Consumer is the application acting as itself, on its Scopes, whatever User its token carries (for example a Keycloak service account). A person cannot get a token through it or call through it. + |- **no client_credentials**: people log in through it and act on their Entitlements; the application never acts as itself through it, and a client-credentials token for it is refused. + | + |The grant types are checked twice. First, when a token is requested: DirectLogin will be blocked for a Consumer whose grant types do not include `direct_login` (logging in with its consumer key fails), and OBP-OIDC and the Keycloak client storage read the grant types from OBP and do not issue a token for a grant that is not included. Second, when a token is used: OBP-API refuses any call through the Consumer that its grant types do not allow, wherever the token was issued. + | + |Grant types are set by an administrator; they are never inferred from the Scopes a Consumer holds or from it being a Platform App (see ${getGlossaryItemLink("Platform Apps")}), so granting a Scope later does not change how a Consumer behaves. + | + |## Endpoints + | + |- [Get Consumer Grant Types](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getConsumerGrantTypes): `GET /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types` + |- [Update Consumer Grant Types](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateConsumerGrantTypes): `PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types`, with the Role CanUpdateConsumerGrantTypes. An empty list clears them. + |""".stripMargin) + glossaryItems += GlossaryItem( title = "Consumer Account Access", description = diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 31d2678362..0d08a0dbf6 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -2123,7 +2123,8 @@ object Http4s600 { GetOidcClientResponseJsonV600( client_id = clientId, client_name = consumer.name.get, consumer_id = consumer.consumerId.get, - redirect_uris = redirectUris, enabled = consumer.isActive.get) + redirect_uris = redirectUris, enabled = consumer.isActive.get, + grant_types = code.consumer.ConsumerGrantTypes.oauth2GrantTypes(consumer)) } } } @@ -2146,7 +2147,8 @@ object Http4s600 { valid = true, client_id = Some(postedData.client_id), consumer_id = Some(consumer.consumerId.get), - redirect_uris = redirectUris) + redirect_uris = redirectUris, + grant_types = Some(code.consumer.ConsumerGrantTypes.oauth2GrantTypes(consumer))) case _ => VerifyOidcClientResponseJsonV600(valid = false) } } @@ -9595,12 +9597,17 @@ object Http4s600 { "Get OIDC Client", s"""Gets an OIDC/OAuth2 client's metadata by client_id. | - |Returns client information including name, consumer_id, redirect_uris, and enabled status. + |Returns client information including name, consumer_id, redirect_uris, enabled status and grant_types. + | + |grant_types lists the OAuth2 grant types the client can be issued tokens for. These are the grant types an + |administrator set on the Consumer (PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types). If none are + |set, they are authorization_code, refresh_token and client_credentials. |This endpoint does not verify the client secret - use POST /oidc/clients/verify for authentication. | - |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules (https; http only for localhost, - |127.0.0.1 or [::1]; or an app scheme in reverse-domain form; no wildcard, user information or fragment). A stored entry - |that fails them is left out, so an OIDC provider never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")} + |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules. A URL must use https, + |or http for localhost, 127.0.0.1 or [::1] only, or an app scheme in reverse-domain form. It must not contain a + |wildcard, user information or a fragment. A stored URL that breaks these rules is left out, so an OIDC provider + |never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")} |${userAuthenticationMessage(true)} |""", @@ -9627,11 +9634,12 @@ object Http4s600 { s"""Verifies an OIDC/OAuth2 client's credentials. | |Returns `valid: true` if the client_id and client_secret match an active consumer. - |Also returns the consumer_id and redirect_uris for use by the OIDC provider. + |Also returns the consumer_id, redirect_uris and grant_types for use by the OIDC provider. | - |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules (https; http only for localhost, - |127.0.0.1 or [::1]; or an app scheme in reverse-domain form; no wildcard, user information or fragment). A stored entry - |that fails them is left out, so an OIDC provider never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")} + |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules. A URL must use https, + |or http for localhost, 127.0.0.1 or [::1] only, or an app scheme in reverse-domain form. It must not contain a + |wildcard, user information or a fragment. A stored URL that breaks these rules is left out, so an OIDC provider + |never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")} |${userAuthenticationMessage(true)} |""", diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 3823a8bce4..4f9506088b 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -179,7 +179,9 @@ case class VerifyOidcClientResponseJsonV600( valid: Boolean, client_id: Option[String] = None, consumer_id: Option[String] = None, - redirect_uris: Option[List[String]] = None + redirect_uris: Option[List[String]] = None, + // The OAuth2 grant types the client may be issued tokens for (see ConsumerGrantTypes) + grant_types: Option[List[String]] = None ) // OIDC Client Get (metadata lookup without secret verification) @@ -188,7 +190,9 @@ case class GetOidcClientResponseJsonV600( client_name: String, consumer_id: String, redirect_uris: List[String], - enabled: Boolean + enabled: Boolean, + // The OAuth2 grant types the client may be issued tokens for: the Consumer's own, or the defaults when it sets none + grant_types: List[String] = code.consumer.ConsumerGrantTypes.DefaultOAuth2 ) case class CallLimitPostJsonV600( diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index b47f3766bb..a26fa7c073 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7692,6 +7692,9 @@ object Http4s700 { // Platform Apps: the Consumers this installation runs as part of its own deployment, and the Scopes they need. resourceDocs ++= Http4s700PlatformApps.resourceDocs + // Consumer grant types: how people and the application itself may call through a Consumer. + resourceDocs ++= Http4s700ConsumerGrantTypes.resourceDocs + // Domain APIs: a space's Dynamic Entities and Dynamic Resource Docs published under a base path of its own. resourceDocs ++= Http4s700DomainApis.resourceDocs diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala new file mode 100644 index 0000000000..d50406502c --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala @@ -0,0 +1,171 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v7_0_0 + +import code.api.Constant.ApiPathZero +import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc} +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.{CallContext, CustomJsonFormats, Glossary, NewStyle} +import code.consumer.{ConsumerGrantTypes, Consumers} +import code.model.Consumer +import code.util.Helper +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.Box +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +import scala.collection.mutable.ArrayBuffer + +/** grant_types: the grant types set on the Consumer (empty if none are set). + * oauth2_grant_types: the grant types an OAuth2 provider may issue tokens for. */ +case class ConsumerGrantTypesJsonV700(consumer_id: String, grant_types: List[String], oauth2_grant_types: List[String]) +case class PutConsumerGrantTypesJsonV700(grant_types: List[String]) + +/** + * The v7.0.0 Consumer grant types endpoints: how people and the application itself may call through a Consumer + * (see [[code.consumer.ConsumerGrantTypes]] and the Glossary item "Consumer Grant Types"). + * + * Declared in its own object to keep Http4s700's initialiser under the JVM's 64KB method limit. + */ +object Http4s700ConsumerGrantTypes { + + implicit val formats: Formats = CustomJsonFormats.formats + + private val implementedInApiVersion = ApiVersion.v7_0_0 + private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + val resourceDocs = ArrayBuffer[ResourceDoc]() + + private def fullOrFail[T: Manifest](box: Box[T], cc: CallContext): T = + code.api.util.APIUtil.unboxFullOrFail(box, Some(cc), UnknownError, 400) + + private def json(consumer: Consumer) = ConsumerGrantTypesJsonV700( + consumer_id = consumer.consumerId.get, + grant_types = ConsumerGrantTypes.of(consumer), + oauth2_grant_types = ConsumerGrantTypes.oauth2GrantTypes(consumer)) + + private val example = ConsumerGrantTypesJsonV700( + consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", + grant_types = List(ConsumerGrantTypes.ClientCredentials), + oauth2_grant_types = List(ConsumerGrantTypes.ClientCredentials)) + + private val description = + s"""A Consumer's grant types control how the Consumer can be used. They say whether people can log in through + |it, whether the application can call the API as itself, or both. + | + |Grant types are optional. A Consumer that has no grant types set can be used in every way, as before. + | + |The values are: + | + |- `authorization_code`: people log in through an OAuth2 / OpenID Connect provider, such as OBP-OIDC or Keycloak. + |- `refresh_token`: such a login can be renewed. Requires `authorization_code`. + |- `client_credentials`: the application gets a token for itself. + |- `direct_login`: people log in with DirectLogin. + | + |For example, a Consumer with only `client_credentials` is used only by the application. Every call through it + |counts as the application acting as itself, and people cannot log in through it. A Consumer without + |`client_credentials` is used only by people. The application cannot get a token for itself through it. + | + |The response has two lists: + | + |- `grant_types`: the grant types set on the Consumer. Empty if none are set. + |- `oauth2_grant_types`: the grant types an OAuth2 provider such as OBP-OIDC may issue tokens for. These are + | the Consumer's grant types without `direct_login`, or all three OAuth2 grant types if none are set. + | + |For more information see ${Glossary.getGlossaryItemLink("Consumer Grant Types")}""".stripMargin + + // Route: GET /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types + lazy val getConsumerGrantTypes: Http4sRoute = Http4sRoute { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId / "grant-types" => + EndpointHelpers.withUser(req) { (_, cc) => + NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)).map(json) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getConsumerGrantTypes), + "GET", + "/management/consumers/CONSUMER_ID/grant-types", + "Get Consumer Grant Types", + s"""Get the grant types of a Consumer. + | + |$description + |""".stripMargin, + EmptyBody, + example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConsumerNotFoundByConsumerId, UnknownError), + List(apiTagConsumer), + Some(List(canGetConsumers)), + http4sPartialFunction = Some(getConsumerGrantTypes) + ) + + // Route: PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types + lazy val updateConsumerGrantTypes: Http4sRoute = Http4sRoute { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "grant-types" => + EndpointHelpers.withUserAndBody[PutConsumerGrantTypesJsonV700, ConsumerGrantTypesJsonV700](req) { (_, body, cc) => + val grantTypes = Option(body.grant_types).getOrElse(Nil).map(_.trim.toLowerCase).filter(_.nonEmpty).distinct + for { + _ <- Helper.booleanToFuture(InvalidConsumerGrantTypes, failCode = 400, cc = Some(cc)) { + ConsumerGrantTypes.unknown(grantTypes).isEmpty && + (!grantTypes.contains(ConsumerGrantTypes.RefreshToken) || grantTypes.contains(ConsumerGrantTypes.AuthorizationCode)) + } + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + updated <- Consumers.consumers.vend.updateConsumerGrantTypes(consumer.id.get, grantTypes) map { + x => fullOrFail(x, cc) + } + } yield json(updated) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(updateConsumerGrantTypes), + "PUT", + "/management/consumers/CONSUMER_ID/grant-types", + "Update Consumer Grant Types", + s"""Set the grant types of a Consumer. This replaces any grant types it already has. + | + |Sending an empty list removes them, so the Consumer can again be used in every way. + | + |$description + |""".stripMargin, + PutConsumerGrantTypesJsonV700(grant_types = List(ConsumerGrantTypes.ClientCredentials)), + example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidConsumerGrantTypes, + ConsumerNotFoundByConsumerId, UnknownError), + List(apiTagConsumer), + Some(List(canUpdateConsumerGrantTypes)), + http4sPartialFunction = Some(updateConsumerGrantTypes) + ) +} diff --git a/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala b/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala new file mode 100644 index 0000000000..7f042f40af --- /dev/null +++ b/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala @@ -0,0 +1,145 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + +*/ +package code.consumer + +import code.api.util.APIUtil.{has2021DirectLoginHeader, hasAnOAuth2Header, hasDirectLoginHeader, isApplicationsOwnUser} +import code.api.util.{CallContext, JwtUtil} +import code.model.Consumer +import com.openbankproject.commons.model.User +import net.liftweb.common.Box + +/** + * A Consumer's grant types: how the Consumer can be used. + * + * They say whether people can log in through the Consumer, whether the application can call the API as itself, + * or both. They are optional, and only an administrator can set them. + * + * A Consumer with no grant types set (every existing Consumer) can be used in every way, exactly as before. + * + * A Consumer with grant types set is used as they say. For example: + * + * - Only client_credentials: only the application uses the Consumer. Every call through it counts as the + * application acting as itself, even if the token carries a user account (such as a Keycloak service + * account). People cannot log in through it. + * - No client_credentials: only people use the Consumer. The application cannot get a token for itself through + * it, and the Consumer's Scopes never count for a call the application makes on its own. + * + * OBP never works out the grant types from other settings, such as the Scopes the Consumer holds or whether it + * is a Platform App. So granting a Consumer a new Scope never changes how it can be used. + * + * The grant types are checked twice: + * + * 1. When a token is requested: by DirectLogin, and by OBP-OIDC and the Keycloak client storage, which read + * the grant types from OBP. + * 2. When a token is used: by OBP-API, in AfterApiAuth.checkConsumerGrantTypes, using refusal() below. + */ +object ConsumerGrantTypes { + + val AuthorizationCode = "authorization_code" + val RefreshToken = "refresh_token" + val ClientCredentials = "client_credentials" + /** OBP's DirectLogin: a person's username and password sent with the Consumer's key. */ + val DirectLogin = "direct_login" + + val Known: List[String] = List(AuthorizationCode, RefreshToken, ClientCredentials, DirectLogin) + + /** The grants by which a person logs in through a Consumer. */ + val PersonLogins: Set[String] = Set(AuthorizationCode, DirectLogin) + + /** The OAuth2 grant types of a Consumer that sets none, as identity providers have treated it so far. */ + val DefaultOAuth2: List[String] = List(AuthorizationCode, RefreshToken, ClientCredentials) + + /** "authorization_code, refresh_token" -> List(authorization_code, refresh_token): trimmed, lower case, distinct. */ + def parse(value: String): List[String] = + Option(value).toList.flatMap(_.split(",")).map(_.trim.toLowerCase).filter(_.nonEmpty).distinct + + /** The unknown values in a list of grant types, if any. */ + def unknown(grantTypes: List[String]): List[String] = grantTypes.filterNot(Known.contains) + + def of(consumer: Consumer): List[String] = parse(consumer.grantTypes.get) + + def isSet(consumer: Consumer): Boolean = of(consumer).nonEmpty + + /** True when the Consumer sets no grant types, or lists this one. */ + def allows(consumer: Consumer, grantType: String): Boolean = !isSet(consumer) || of(consumer).contains(grantType) + + /** Only the application acting as itself: client_credentials and no login grant. */ + def isClientCredentialsOnly(consumer: Consumer): Boolean = + isSet(consumer) && of(consumer).contains(ClientCredentials) && !of(consumer).exists(PersonLogins.contains) + + /** Grant types set, client_credentials not among them: the application never acts as itself through it. */ + def excludesClientCredentials(consumer: Consumer): Boolean = isSet(consumer) && !of(consumer).contains(ClientCredentials) + + /** The OAuth2 grant types an identity provider may issue tokens for (DirectLogin is OBP's own, not OAuth2). */ + def oauth2GrantTypes(consumer: Consumer): List[String] = + if (isSet(consumer)) of(consumer).filterNot(_ == DirectLogin) else DefaultOAuth2 + + /** + * Whether a JWT access token was issued by client credentials, to the application itself: + * - OBP-OIDC marks it with the claim grant_type = client_credentials; + * - a token whose subject is its own client (sub = azp), as OBP-OIDC and others issue it; + * - Keycloak issues it to the client's service account, preferred_username "service-account-" + the client id. + */ + def isClientCredentialsToken(jwt: String): Boolean = { + def claim(name: String): Option[String] = Option(JwtUtil.getClaim(name = name, jwtToken = jwt)).filter(_.nonEmpty) + val azp = claim("azp").orElse(claim("client_id")) + claim("grant_type").contains(ClientCredentials) || + (claim("sub").nonEmpty && claim("sub") == azp) || + azp.exists(clientId => claim("preferred_username").contains(s"service-account-$clientId")) + } + + private def bearerToken(cc: CallContext): Option[String] = + cc.authReqHeaderField.toOption.filter(h => hasAnOAuth2Header(Box !! h)) + .map(_.replaceAll("Authorization:", "").replaceAll("Bearer", "").trim).filter(_.nonEmpty) + + /** + * Checks whether this call may go through this Consumer. Returns None if it may. If it may not, returns the + * grant type the call would need, for the error message. + * + * Only Consumers with grant types set are checked. A call without a user is never refused here. In that case + * only the Consumer's key or certificate identifies the caller, and nobody has logged in. The endpoint's own + * access check then decides whether the application can make the call. + */ + def refusal(consumer: Consumer, user: Box[User], cc: CallContext): Option[String] = { + if (!isSet(consumer) || user.isEmpty) None + else { + val directLogin = has2021DirectLoginHeader(cc.requestHeaders) || hasDirectLoginHeader(cc.authReqHeaderField) + val bearer = bearerToken(cc) + // The application's own User (OBP-OIDC's client provider) is never a person: nobody can sign up or log in + // with a password there, whichever header carries its token. + val applicationItself = + user.exists(isApplicationsOwnUser(_, consumer)) || (!directLogin && bearer.exists(isClientCredentialsToken)) + if (applicationItself) { + if (allows(consumer, ClientCredentials)) None else Some(ClientCredentials) + } else { + val login = if (directLogin) DirectLogin else if (bearer.isDefined) AuthorizationCode else s"$AuthorizationCode or $DirectLogin" + val allowed = if (directLogin || bearer.isDefined) allows(consumer, login) else of(consumer).exists(PersonLogins.contains) + if (allowed) None else Some(login) + } + } + } +} diff --git a/obp-api/src/main/scala/code/consumer/ConsumerProvider.scala b/obp-api/src/main/scala/code/consumer/ConsumerProvider.scala index 2acfdd8c0f..17b0e1b4e0 100644 --- a/obp-api/src/main/scala/code/consumer/ConsumerProvider.scala +++ b/obp-api/src/main/scala/code/consumer/ConsumerProvider.scala @@ -84,6 +84,8 @@ trait ConsumersProvider { certificate: Option[String] = None, ): Box[Consumer] @deprecated("Use RateLimitingDI.rateLimiting.vend methods instead", "v5.0.0") + /** Sets the grant types the Consumer allows; an empty list clears them (see ConsumerGrantTypes). */ + def updateConsumerGrantTypes(id: Long, grantTypes: List[String]): Future[Box[Consumer]] def updateConsumerCallLimits(id: Long, perSecond: Option[String], perMinute: Option[String], perHour: Option[String], perDay: Option[String], perWeek: Option[String], perMonth: Option[String]): Future[Box[Consumer]] def getOrCreateConsumer(consumerId: Option[String], key: Option[String], diff --git a/obp-api/src/main/scala/code/model/OAuth.scala b/obp-api/src/main/scala/code/model/OAuth.scala index fd19f32b09..2ce9b5eddb 100644 --- a/obp-api/src/main/scala/code/model/OAuth.scala +++ b/obp-api/src/main/scala/code/model/OAuth.scala @@ -314,6 +314,10 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable { } } + override def updateConsumerGrantTypes(id: Long, grantTypes: List[String]): Future[Box[Consumer]] = Future { + Consumer.find(By(Consumer.id, id)).flatMap(c => tryo(c.grantTypes(grantTypes.mkString(",")).saveMe())) + } + def updateConsumerCallLimitsRemote(id: Long, perSecond: Option[String], perMinute: Option[String], @@ -653,6 +657,9 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{ // FAPI 1.0 Advanced: URL where this client publishes its JWKS, used to verify // signed request objects and private_key_jwt client assertions (OBP-OIDC). object jwksUri extends MappedString(this, 500) + // The OAuth2 grant types (and direct_login) this Consumer allows, comma separated, set by an administrator. + // Empty: not set, every way of calling works as before. See code.consumer.ConsumerGrantTypes. + object grantTypes extends MappedString(this, 250) object company extends MappedString(this, 100) { override def displayName = "Company:" } diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql index 20f2b9a0a0..eb646b7cc0 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql @@ -29,6 +29,7 @@ name ,userauthenticationurl ,clientcertificate ,jwksuri +,granttypes ,company ,key_c ,isactive diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql index aa0c2bd842..851d8a0699 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql @@ -5,8 +5,10 @@ -- CREATE VIEW v_oidc_clients -- ============================================================================= -- This script creates a read-only view exposing necessary consumer fields for OIDC --- Note: Some OIDC-specific fields like grant_types and scopes may not exist in current schema --- TODO: Add grant_types and scopes fields to consumer table if needed for full OIDC compliance +-- grant_types come from the consumer's granttypes column, set by an administrator (OBP-API's +-- PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types). A consumer that sets none gets the defaults +-- authorization_code, refresh_token and client_credentials. direct_login is OBP's own, not an OAuth2 grant: left out. +-- Note: scopes are still hardcoded defaults. -- Drop the view if it already exists DROP VIEW IF EXISTS v_oidc_clients CASCADE; @@ -20,7 +22,10 @@ SELECT secret, -- The OAuth1 secret secret as client_secret, -- The OAuth2 secret redirecturl as redirect_uris, - 'authorization_code,refresh_token' as grant_types, -- Default OIDC grant types + CASE + WHEN COALESCE(TRIM(granttypes), '') = '' THEN 'authorization_code,refresh_token,client_credentials' + ELSE TRIM(BOTH ',' FROM REGEXP_REPLACE(REPLACE(granttypes, ' ', ''), '(^|,)direct_login(?=,|$)', '', 'g')) + END as grant_types, -- The consumer's grant types, or the defaults 'openid,profile,email' as scopes, -- Default OIDC scopes name as client_name, 'code' as response_types, @@ -33,7 +38,7 @@ WHERE isactive = true -- Only expose active consumers to OIDC service ORDER BY client_name; -- Add comment to the view for documentation -COMMENT ON VIEW v_oidc_clients IS 'Read-only view of consumer table for OIDC service access. Only includes active consumers. Note: grant_types and scopes are hardcoded defaults - consider adding these fields to consumer table for full OIDC compliance.'; +COMMENT ON VIEW v_oidc_clients IS 'Read-only view of consumer table for OIDC service access. Only includes active consumers. grant_types are the consumer''s own (granttypes), or authorization_code, refresh_token and client_credentials when it sets none. scopes are hardcoded defaults.'; -- Grant SELECT permission on the OIDC view (oidc_user - read-only access) -- not sure OIDC_USER needs this. diff --git a/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala new file mode 100644 index 0000000000..969d3aaf5b --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala @@ -0,0 +1,222 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + +*/ +package code.api.v7_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.APIUtil.isApplicationActingAsItself +import code.api.util.ApiRole.{CanGetAnyUser, CanGetConsumers, CanUpdateConsumerGrantTypes} +import code.api.util.ErrorMessages.{InvalidConsumerGrantTypes, UserHasMissingRoles} +import code.api.v6_0_0.V600ServerSetup +import code.consumer.{ConsumerGrantTypes, Consumers} +import code.entitlement.Entitlement +import code.model.Consumer +import code.model.dataAccess.AuthUser +import code.scope.Scope +import code.setup.TestPasswordConfig +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import org.json4s.JsonDSL._ +import org.json4s.native.JsonMethods.{compact, render} +import org.scalatest.Tag + +class ConsumerGrantTypesTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object ApiEndpoint1 extends Tag("getConsumerGrantTypes") + object ApiEndpoint2 extends Tag("updateConsumerGrantTypes") + + def v7 = baseRequest / "obp" / "v7.0.0" + private def grantTypesPath(consumerId: String) = v7 / "management" / "consumers" / consumerId / "grant-types" + private def body(grantTypes: String*) = compact(render("grant_types" -> grantTypes.toList)) + private def message(r: code.setup.APIResponse) = r.body.extract[ErrorMessage].message + + /** Sets testConsumer2's grant types for the block, then clears them. */ + private def withGrantTypes(grantTypes: String*)(f: => Unit): Unit = { + await(Consumers.consumers.vend.updateConsumerGrantTypes(testConsumer2.id.get, grantTypes.toList)) + try f finally await(Consumers.consumers.vend.updateConsumerGrantTypes(testConsumer2.id.get, Nil)) + } + private def await[T](f: scala.concurrent.Future[T]): T = scala.concurrent.Await.result(f, scala.concurrent.duration.Duration(10, "seconds")) + + private def jwt(claims: (String, String)*): String = { + val builder = claims.foldLeft(new JWTClaimsSet.Builder()) { case (b, (k, v)) => b.claim(k, v) } + val signed = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), builder.build()) + signed.sign(new MACSigner(("x" * 32).getBytes("UTF-8"))) + signed.serialize() + } + + feature("The rules") { + + scenario("parsing and the two kinds of Consumer", VersionOfApi) { + def consumer(value: String) = Consumer.create.grantTypes(value) + ConsumerGrantTypes.parse(" Client_Credentials, ,client_credentials ") should equal(List("client_credentials")) + ConsumerGrantTypes.unknown(List("client_credentials", "password")) should equal(List("password")) + + val notSet = consumer("") + ConsumerGrantTypes.isSet(notSet) should be(false) + ConsumerGrantTypes.allows(notSet, ConsumerGrantTypes.DirectLogin) should be(true) + ConsumerGrantTypes.oauth2GrantTypes(notSet) should equal(ConsumerGrantTypes.DefaultOAuth2) + + val applicationOnly = consumer("client_credentials") + ConsumerGrantTypes.isClientCredentialsOnly(applicationOnly) should be(true) + ConsumerGrantTypes.allows(applicationOnly, ConsumerGrantTypes.DirectLogin) should be(false) + + val peopleOnly = consumer("authorization_code,refresh_token,direct_login") + ConsumerGrantTypes.excludesClientCredentials(peopleOnly) should be(true) + ConsumerGrantTypes.oauth2GrantTypes(peopleOnly) should equal(List("authorization_code", "refresh_token")) + + val both = consumer("authorization_code,client_credentials") + ConsumerGrantTypes.isClientCredentialsOnly(both) should be(false) + ConsumerGrantTypes.excludesClientCredentials(both) should be(false) + } + + scenario("client-credentials tokens are recognised", VersionOfApi) { + ConsumerGrantTypes.isClientCredentialsToken(jwt("grant_type" -> "client_credentials", "sub" -> "u1", "azp" -> "app")) should be(true) + ConsumerGrantTypes.isClientCredentialsToken(jwt("sub" -> "app", "azp" -> "app")) should be(true) + // Keycloak's service account + ConsumerGrantTypes.isClientCredentialsToken(jwt("sub" -> "9b1c", "azp" -> "app", "preferred_username" -> "service-account-app")) should be(true) + // A person + ConsumerGrantTypes.isClientCredentialsToken(jwt("sub" -> "9b1c", "azp" -> "app", "preferred_username" -> "alice")) should be(false) + } + + scenario("a Consumer's grant types decide whether the application acts as itself", VersionOfApi) { + val consumerPk = testConsumer2.id.get.toString + isApplicationActingAsItself(resourceUser2.userId, consumerPk) should be(false) + withGrantTypes("client_credentials") { + isApplicationActingAsItself(resourceUser2.userId, consumerPk) should be(true) + } + withGrantTypes("authorization_code") { + isApplicationActingAsItself("", consumerPk) should be(false) + } + } + } + + feature("Get and Update Consumer Grant Types") { + + scenario("need their Roles", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + val get = makeGetRequest(grantTypesPath(testConsumer2.consumerId.get).GET <@ (user3)) + get.code should equal(403) + message(get) should include(UserHasMissingRoles) + val put = makePutRequest(grantTypesPath(testConsumer2.consumerId.get).PUT <@ (user3), body("client_credentials")) + put.code should equal(403) + message(put) should include(UserHasMissingRoles) + } + + scenario("set, read and clear them; unknown values are refused", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateConsumerGrantTypes.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetConsumers.toString) + val path = grantTypesPath(testConsumer2.consumerId.get) + try { + val unknown = makePutRequest(path.PUT <@ (user1), body("password")) + unknown.code should equal(400) + message(unknown) should include(InvalidConsumerGrantTypes) + makePutRequest(path.PUT <@ (user1), body("refresh_token")).code should equal(400) + + val set = makePutRequest(path.PUT <@ (user1), body("Client_Credentials")) + set.code should equal(200) + (set.body \ "grant_types").extract[List[String]] should equal(List("client_credentials")) + + val read = makeGetRequest(path.GET <@ (user1)) + read.code should equal(200) + (read.body \ "oauth2_grant_types").extract[List[String]] should equal(List("client_credentials")) + + val cleared = makePutRequest(path.PUT <@ (user1), body()) + cleared.code should equal(200) + (cleared.body \ "grant_types").extract[List[String]] should equal(Nil) + (cleared.body \ "oauth2_grant_types").extract[List[String]] should equal(ConsumerGrantTypes.DefaultOAuth2) + } finally { + await(Consumers.consumers.vend.updateConsumerGrantTypes(testConsumer2.id.get, Nil)) + } + } + } + + feature("Calls through a Consumer follow its grant types") { + + def currentUser = (v7 / "users" / "current").GET + def users = (v6_0_0_Request / "users").GET + + scenario("not set: people and the application call through it as before", VersionOfApi) { + makeGetRequest(currentUser <@ (user2)).code should equal(200) + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, CanGetAnyUser.toString) + try makeGetRequest(users <@ (application2)).code should equal(200) + finally Scope.scope.vend.deleteScope(scope) + } + + scenario("client credentials only: a person is refused, the application acts as itself", VersionOfApi) { + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, CanGetAnyUser.toString) + try withGrantTypes("client_credentials") { + val person = makeGetRequest(currentUser <@ (user2)) + person.code should equal(401) + message(person) should include("OBP-20410") + val application = makeGetRequest(users <@ (application2)) + withClue(application.body) { application.code should equal(200) } + } finally Scope.scope.vend.deleteScope(scope) + } + + scenario("no client credentials: the application is refused, a person calls as before", VersionOfApi) { + // The test requests carry their tokens as DirectLogin, so the person needs direct_login here. + withGrantTypes("authorization_code", "direct_login") { + val application = makeGetRequest(users <@ (application2)) + application.code should equal(401) + message(application) should include("OBP-20410") + val person = makeGetRequest(currentUser <@ (user2)) + withClue(person.body) { person.code should equal(200) } + } + } + } + + feature("DirectLogin follows the Consumer's grant types") { + + scenario("a Consumer without direct_login issues no DirectLogin token", VersionOfApi) { + val key = randomString(40).toLowerCase + val username = "grant.types." + randomString(8).toLowerCase + val password = TestPasswordConfig.VALID_PASSWORD + AuthUser.create.email(s"$username@example.com").username(username).password(password).validated(true) + .firstName("Grant").lastName("Types").saveMe() + val consumer = Consumers.consumers.vend.createConsumer(Some(key), Some(randomString(40).toLowerCase), Some(true), + Some("grant types test " + randomString(6)), None, Some("description"), Some("grant.types@example.com"), + None, None, None, None, None).openOrThrowException("consumer") + val login = v6_0_0_Request / "my" / "logins" / "direct" + val headers = List(("DirectLogin", s"username=$username, password=$password, consumer_key=$key")) + try { + await(Consumers.consumers.vend.updateConsumerGrantTypes(consumer.id.get, List("client_credentials"))) + val refused = makePostRequestAdditionalHeader(login, "", headers) + refused.code should equal(401) + message(refused) should include("OBP-20410") + + await(Consumers.consumers.vend.updateConsumerGrantTypes(consumer.id.get, List("direct_login"))) + makePostRequestAdditionalHeader(login, "", headers).code should equal(201) + } finally { + AuthUser.find(By(AuthUser.username, username)).foreach(_.delete_!) + } + } + } +} diff --git a/scripts/plain_english_check.py b/scripts/plain_english_check.py new file mode 100755 index 0000000000..ff399c39d2 --- /dev/null +++ b/scripts/plain_english_check.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Checks OBP-API's documentation for long sentences, one of the plain English rules in README.md +("Writing documentation"). Sentences should average 15 to 20 words, and any sentence over 25 words +should be split. + +It checks three kinds of text: + + resource-docs the summary and description of every ResourceDoc + glossary the description of every glossary item (Glossary.scala) + errors every error message (ErrorMessages.scala) + +Code, tables, links, URLs and interpolated values (${...}) are left out, so only prose is measured. +Each heading and list item counts as a sentence of its own. + +The check only measures sentence length. It cannot tell whether a text is clear, so a short text can still +need rewriting. Read the guides linked in README.md as well. + +Usage: + scripts/plain_english_check.py a summary, then the 20 longest sentences + scripts/plain_english_check.py --changed only texts you have changed since the last commit + scripts/plain_english_check.py --kind glossary --name "Consumer Grant Types" + scripts/plain_english_check.py --all every long sentence + scripts/plain_english_check.py --strict exit with status 1 if any sentence is too long + +By default the script only reports, so it can run against the existing texts, many of which are too long. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_lift_http4s_resource_doc_parity import ( # noqa: E402 + endpoint_name, + find_resourcedoc_blocks, + parse_resourcedoc, + scan_matching_close, + strip_inline_comments, +) + +REPO = Path(__file__).resolve().parent.parent +SRC = REPO / "obp-api" / "src" / "main" / "scala" / "code" +GLOSSARY = SRC / "api" / "util" / "Glossary.scala" +ERRORS = SRC / "api" / "util" / "ErrorMessages.scala" + +MAX_WORDS = 25 + +# A Scala string literal: triple-quoted (optionally s or f interpolated) or plain double-quoted. +LITERAL_RE = re.compile(r'[sf]?"""(.*?)"""(?!")|[sf]?"((?:[^"\\\n]|\\.)*)"', re.S) + + +def literals(code: str) -> str: + """The text of every string literal in a piece of Scala code, joined.""" + return "\n".join((m.group(1) if m.group(1) is not None else m.group(2)) for m in LITERAL_RE.finditer(code)) + + +def prose_lines(text: str) -> list[str]: + """The text as lines of prose: margins, code, tables, links and interpolations removed.""" + text = re.sub(r"^[ \t]*\|", "", text, flags=re.M) # stripMargin + text = re.sub(r"```.*?```", "\n", text, flags=re.S) # fenced code + text = re.sub(r"<[^>]+>", " ", text) # HTML tags + text = re.sub(r"\$\{[^}]*\}|\$[A-Za-z_]\w*", "X", text) # interpolations + text = re.sub(r"`[^`]*`", "X", text) # inline code + text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text) # links + text = re.sub(r"https?://\S+", "URL", text) + text = text.replace("\\n", "\n") + lines = [] + for line in text.split("\n"): + if re.match(r"^\s*\|", line) or re.match(r"^( {4}|\t)", line): # table rows, indented code + lines.append("") + else: + lines.append(line) + return lines + + +def sentences(text: str) -> list[str]: + """Sentences in the text. Headings and list items stand alone; paragraphs are split on . ! ?""" + out, paragraph = [], [] + + def flush(): + joined = " ".join(paragraph).strip() + if joined: + out.extend(s.strip() for s in re.split(r"(?<=[.!?])\s+(?=[A-Z0-9X\"'(])", joined) if s.strip()) + paragraph.clear() + + for line in prose_lines(text): + stripped = line.strip() + if not stripped: + flush() + elif re.match(r"^(#+|[-*]|\d+\.)\s", stripped): + flush() + paragraph.append(re.sub(r"^(#+|[-*]|\d+\.)\s+", "", stripped)) + flush() if stripped.startswith("#") else None + else: + paragraph.append(stripped) + flush() + # Keep prose only: drop diagrams and other text that is mostly symbols. + def is_prose(s: str) -> bool: + chars = [c for c in s if not c.isspace()] + return bool(re.search(r"[A-Za-z]{2}", s)) and sum(c.isalpha() for c in chars) >= 0.6 * len(chars) + return [s for s in out if is_prose(s)] + + +def words(sentence: str) -> int: + return len(sentence.split()) + + +def line_of(raw: str, needle: str) -> int: + i = raw.find(needle) + return raw.count("\n", 0, i) + 1 if i >= 0 else 0 + + +# ---- Collecting the texts: (kind, name, file, first line, last line, text) + +def resource_doc_texts(files): + for path in files: + raw = path.read_text(encoding="utf-8") + if "ResourceDoc(" not in raw or "Http4sRoute" not in raw: + continue + source = strip_inline_comments(raw) + for _, _, body in find_resourcedoc_blocks(source): + args = parse_resourcedoc(body) + if "partialFunctionName" not in args: + continue + name = endpoint_name(args["partialFunctionName"]) + text = "\n\n".join(literals(args.get(field, "") or "") for field in ("summary", "description")) + first = line_of(raw, f"nameOf({name})") + yield "resource-docs", name, path, first, first + body.count("\n"), text + + +def glossary_texts(): + raw = GLOSSARY.read_text(encoding="utf-8") + for m in re.finditer(r"glossaryItems\s*\+=\s*GlossaryItem\s*\(", raw): + close = scan_matching_close(raw, m.end(), "(", ")") + if close is None: + continue + block = raw[m.end():close] + title = re.search(r'title\s*=\s*"([^"]*)"', block) + description = block[block.find("description"):] if "description" in block else "" + yield ("glossary", title.group(1) if title else "?", GLOSSARY, raw.count("\n", 0, m.start()) + 1, + raw.count("\n", 0, close) + 1, literals(description)) + + +def error_texts(): + raw = ERRORS.read_text(encoding="utf-8") + for m in re.finditer(r'^\s*val\s+(\w+)\s*=\s*(.+)$', raw, re.M): + text = literals(m.group(2)) + if text: + line = raw.count("\n", 0, m.start()) + 1 + yield "errors", m.group(1), ERRORS, line, line, text + + +def changed_lines() -> dict[Path, set[int] | None]: + """The lines changed in the working tree, per Scala file, compared with the last commit. + A new file maps to None: all of it counts as changed.""" + def git(*args): + return subprocess.run(["git", "-c", f"safe.directory={REPO}", "-C", str(REPO), *args], + capture_output=True, text=True, check=True).stdout + changed: dict[Path, set[int] | None] = {} + current = None + for line in git("diff", "-U0", "HEAD", "--", "*.scala").splitlines(): + if line.startswith("+++ b/"): + current = (REPO / line[6:]).resolve() + changed.setdefault(current, set()) + elif line.startswith("@@") and current is not None: + m = re.search(r"\+(\d+)(?:,(\d+))?", line) + start, count = int(m.group(1)), int(m.group(2) or 1) + changed[current].update(range(start, start + max(count, 1))) + for line in git("ls-files", "--others", "--exclude-standard", "--", "*.scala").splitlines(): + changed[(REPO / line).resolve()] = None + return changed + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--kind", choices=["resource-docs", "glossary", "errors", "all"], default="all") + ap.add_argument("--name", help="only texts whose name (endpoint, glossary title or error) contains this") + ap.add_argument("--changed", action="store_true", help="only texts changed since the last commit (new files: all of them)") + ap.add_argument("--max-words", type=int, default=MAX_WORDS, help=f"longest sentence allowed (default {MAX_WORDS})") + ap.add_argument("--all", action="store_true", help="list every long sentence, not only the 20 longest") + ap.add_argument("--strict", action="store_true", help="exit with status 1 if any sentence is too long") + args = ap.parse_args() + + scala_files = sorted((SRC / "api").rglob("*.scala")) + only = changed_lines() if args.changed else None + texts = [] + if args.kind in ("resource-docs", "all"): + texts += list(resource_doc_texts(scala_files)) + if args.kind in ("glossary", "all"): + texts += list(glossary_texts()) + if args.kind in ("errors", "all"): + texts += list(error_texts()) + if only is not None: + def touched(t): + path = t[2].resolve() + if path not in only: + return False + lines = only[path] + return lines is None or any(t[3] <= n <= t[4] for n in lines) + texts = [t for t in texts if touched(t)] + if args.name: + texts = [t for t in texts if args.name.lower() in t[1].lower()] + + long_sentences, totals = [], {} + for kind, name, path, line, _, text in texts: + ss = sentences(text) + t = totals.setdefault(kind, {"texts": 0, "sentences": 0, "words": 0, "long": 0, "texts_with_long": 0}) + t["texts"] += 1 + t["sentences"] += len(ss) + t["words"] += sum(words(s) for s in ss) + long = [s for s in ss if words(s) > args.max_words] + t["long"] += len(long) + t["texts_with_long"] += 1 if long else 0 + long_sentences += [(words(s), kind, name, path, line, s) for s in long] + + if not texts: + print("No texts to check.") + return + print(f"Sentences over {args.max_words} words:\n") + for kind, t in totals.items(): + average = t["words"] / t["sentences"] if t["sentences"] else 0 + print(f" {kind:<14} {t['long']:>5} long sentences in {t['texts_with_long']} of {t['texts']} texts " + f"(average sentence {average:.1f} words)") + + long_sentences.sort(key=lambda x: -x[0]) + shown = long_sentences if args.all else long_sentences[:20] + if shown: + print(f"\n{'Every long sentence' if args.all else 'The longest sentences'}, longest first:\n") + for n, kind, name, path, line, s in shown: + print(f" [{n} words] {path.relative_to(REPO)}:{line} {kind} {name}") + print(f" {s[:220]}{'...' if len(s) > 220 else ''}") + if len(long_sentences) > len(shown): + print(f"\n ... and {len(long_sentences) - len(shown)} more. Use --all to list them, or --name to narrow down.") + + if args.strict and long_sentences: + sys.exit(1) + + +if __name__ == "__main__": + main() From e1fbb6fc35e44f4539ad6a9e5a9ef55fb0d6cc09 Mon Sep 17 00:00:00 2001 From: Simon Redfern Date: Tue, 10 Nov 2026 10:03:52 +0100 Subject: [PATCH 3/4] Endpoint auth modes are removed: the ResourceDoc field authMode, with its values UserOnly, ApplicationOnly, UserXORApplication and UserAndApplication. Who is acting on a request now decides where a Role comes from, the same way on every endpoint. A person needs the Role as an Entitlement. An application acting as itself needs it as a Scope on its Consumer. An application acts as itself only if its Consumer is a Platform App, and it calls with no user or as its own client-credentials user. Keycloak service-account tokens now get such a user too, with provider keycloak#client, named after the client id. Without the Platform App mark, a Consumer's Scopes only work as a limit on people. Operators must mark the applications that act as themselves, such as the Portal, API Manager, Cohort, Sentinel and Bank-Node. handleAccessControlRegardingEntitlementsAndScopes is now the only access check. The middleware, inline Role checks in handlers, Dynamic Entities, Files and the gRPC streams all use it. Consumer Account Access now works on every endpoint of the granted view. Who may call an endpoint is read from its error list only. "User not logged in" or a Role means a user is required. "The application cannot be identified" without it means an identified application is enough, and a user is optional. The 22 Berlin Group and UK Open Banking consent and payment endpoints now say this. An anonymous call to an endpoint with a Role gets OBP-20001 again, not OBP-20200. Dynamic Entities: auth_mode is removed from the v6.0.0 and v7.0.0 definitions and is no longer stored. A client that still sends it is not refused, because the field is ignored. The old column stays in existing databases, unused. An entity's data endpoints work like static endpoints with a Role. v7.0.0 Get My Role returns allowed, true or false, in place of allowed_for_auth_modes. Consumer grant types now only control how tokens can be obtained through a Consumer. They no longer decide whether a call is the application acting as itself. The glossary item Consumer Grant Types is rewritten in plain English, and says what it means to log in through a Consumer. Glossary: API.Entitlements and Scopes replaces API.Endpoint Auth Modes. A new item, OAuth Standards and OBP, lists the OAuth standards OBP works with. The introductory documentation, sample.props.template and the release notes are updated to match. The note todo/todo_remove_endpoint_auth_mode.md records the decisions behind this change. Tests: EndpointAccessRuleTest replaces EndpointAuthModeTest. The new UserOptionalHandlerTest fails if a handler whose doc needs only an identified application insists on a user. DynamicEntityAuthModeTest checks that auth_mode is ignored. Tests of anonymous calls expect OBP-20001. The test applications are marked as Platform Apps. The failure sweep now covers the former UserXORApplication endpoints, and accepts the 503 of Send Email to Customer when no From address is set. --- .../docs/introductory_system_documentation.md | 41 ++-- .../resources/props/sample.props.template | 11 +- obp-api/src/main/scala/code/api/OAuth2.scala | 16 +- .../OpenAPI31JSONFactory.scala | 3 +- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 20 +- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 20 +- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 53 ++--- .../berlin/group/v1_3/Http4sBGv13PIIS.scala | 2 +- .../berlin/group/v1_3/Http4sBGv13PIS.scala | 15 +- .../dynamic/entity/Http4sDynamicEntity.scala | 35 ++- .../entity/helper/DynamicEntityHelper.scala | 42 ++-- .../entity/helper/DynamicEntitySpace.scala | 10 +- .../main/scala/code/api/util/APIUtil.scala | 202 ++++++------------ .../main/scala/code/api/util/ApiSession.scala | 2 +- .../main/scala/code/api/util/Glossary.scala | 127 +++++++---- .../util/http4s/ResourceDocMiddleware.scala | 25 +-- .../scala/code/api/v4_0_0/Http4s400.scala | 1 - .../scala/code/api/v5_1_0/Http4s510.scala | 9 - .../scala/code/api/v6_0_0/Http4s600.scala | 23 +- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 12 +- .../scala/code/api/v7_0_0/Http4s700.scala | 16 +- .../Http4s700ConsumerAccountAccess.scala | 6 +- .../v7_0_0/Http4s700ConsumerGrantTypes.scala | 16 +- .../api/v7_0_0/Http4s700CustomerEmails.scala | 3 +- .../Http4s700DynamicEntityDefinitions.scala | 10 +- .../code/api/v7_0_0/Http4s700Files.scala | 18 +- .../code/api/v7_0_0/Http4s700Grants.scala | 6 +- .../code/api/v7_0_0/Http4s700MyRoles.scala | 48 ++--- .../api/v7_0_0/Http4s700OpenIdConnect.scala | 3 +- .../api/v7_0_0/Http4s700PlatformApps.scala | 3 +- .../code/api/v7_0_0/Http4s700Telemetry.scala | 3 +- .../code/consumer/ConsumerGrantTypes.scala | 26 +-- .../dynamicEntity/DynamicEntityProvider.scala | 52 +---- .../MapppedDynamicEntityProvider.scala | 8 +- .../logcache/LogCacheStreamServiceImpl.scala | 3 +- .../MetricsStreamServiceImpl.scala | 3 +- .../v3_1_0/UKOpenBankingV310AisTests.scala | 10 +- .../UKOpenBankingV401AccountInfoTests.scala | 12 +- .../UKOpenBankingV401ConsentAccessTests.scala | 12 +- .../AccountInformationServiceAISApiTest.scala | 8 +- .../BerlinGroupV13ConsentAccessTests.scala | 8 +- .../scala/code/api/sweep/AuthSweepTest.scala | 10 +- .../code/api/sweep/EndpointCatalog.scala | 11 +- .../code/api/sweep/FailureSweepTest.scala | 5 +- .../util/http4s/UserOptionalHandlerTest.scala | 182 ++++++++++++++++ .../api/v4_0_0/UserCustomerLinkTest.scala | 3 +- .../scala/code/api/v5_1_0/ConsumerTest.scala | 5 +- .../v5_1_0/JustInTimeEntitlementsTest.scala | 21 +- .../api/v5_1_0/LogCacheEndpointTest.scala | 4 +- .../test/scala/code/api/v5_1_0/UserTest.scala | 4 +- .../api/v6_0_0/AggregateMetricsTest.scala | 6 +- .../v6_0_0/DynamicEntityAuthModeTest.scala | 72 +++---- .../code/api/v6_0_0/DynamicEntityTest.scala | 6 +- .../api/v6_0_0/EndpointAccessRuleTest.scala | 120 +++++++++++ .../api/v6_0_0/EndpointAuthModeTest.scala | 83 ------- .../code/api/v6_0_0/GetOidcClientTest.scala | 2 +- .../code/api/v6_0_0/GetUserByUserIdTest.scala | 4 +- .../scala/code/api/v6_0_0/GetUsersTest.scala | 4 +- .../UserLookupApplicationAccessTest.scala | 21 +- .../api/v6_0_0/VerifyOidcClientTest.scala | 2 +- .../v6_0_0/VerifyUserCredentialsTest.scala | 6 +- .../api/v7_0_0/ConsumerGrantTypesTest.scala | 19 +- .../v7_0_0/DynamicEntityDefinitionTest.scala | 2 +- .../code/api/v7_0_0/GetConsumersTest.scala | 4 +- .../code/api/v7_0_0/GetOidcConsentTest.scala | 2 +- .../code/api/v7_0_0/GrantsEndpointTest.scala | 6 +- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 8 +- .../code/api/v7_0_0/PlatformAppsTest.scala | 2 + .../api/v7_0_0/TelemetryEndpointTest.scala | 4 +- .../test/scala/code/setup/DefaultUsers.scala | 9 +- release_notes.md | 27 +++ .../parity_allowlist.json | 28 +-- todo/todo_remove_endpoint_auth_mode.md | 185 ++++++++++++++++ 73 files changed, 1032 insertions(+), 778 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/util/http4s/UserOptionalHandlerTest.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/EndpointAccessRuleTest.scala delete mode 100644 obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala create mode 100644 todo/todo_remove_endpoint_auth_mode.md diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index f947e2b551..7e45569abd 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -40,7 +40,7 @@ For more detailed information or the sources of truths, please refer to the indi - 8.4 [Rate Limiting](#84-rate-limiting) - 8.5 [Attribute-Based Access Control (ABAC)](#85-attribute-based-access-control-abac) - 8.6 [User Attributes and Personal Data Fields](#86-user-attributes-and-personal-data-fields) - - 8.7 [Endpoint Auth Modes](#87-endpoint-auth-modes) + - 8.7 [Entitlements and Scopes](#87-entitlements-and-scopes) - 8.8 [Maker/Checker for Transaction Requests](#88-makercchecker-for-transaction-requests) - 8.9 [Mandates](#89-mandates) - 8.10 [Security Best Practices](#810-security-best-practices) @@ -139,7 +139,7 @@ The Open Bank Project (OBP) is an open-source RESTful API platform for banks tha - **Views System**: Account data visibility control (owner, public, accountant, auditor, custom) - **Scope Management**: OAuth 2.0 scope definitions - **Authentication Type Validation**: Enforce authentication requirements per endpoint -- **Endpoint Auth Modes**: Per-endpoint control over user vs application authentication (UserOnly, ApplicationOnly, UserXORApplication, UserAndApplication) +- **Entitlements and Scopes**: a person needs a Role as an Entitlement; a Platform App acting as itself needs it as a Scope - **Mandates**: Corporate account governance with provisions, signatory panels, and multi-signature approval #### 1.2.9 Extensibility & Customization @@ -2699,7 +2699,7 @@ OBP can bootstrap a Consumer (Application) for OBP-OIDC at startup. This allows The bootstrap consumer is granted the following Scopes: `CanGetConsumers`, `CanCreateConsumer`, `CanVerifyOidcClient`, `CanGetOidcClient`, `CanGetOidcConsent`. -These endpoints use `authMode = UserXORApplication`, meaning they can be accessed either by a logged-in User with Entitlements, or by the Application acting as itself (e.g. with a client-credentials token) with its Consumer's Scopes. A User calling through the Consumer does not get its Scopes. +These endpoints can be called by a logged-in User with Entitlements, or by the Application acting as itself with its Consumer's Scopes (see 8.7). A User calling through the Consumer does not get its Scopes. ```properties # Bootstrap OIDC Operator Consumer @@ -3006,34 +3006,33 @@ DELETE /obp/v6.0.0/my/personal-data-fields/USER_ATTRIBUTE_ID # Delete | Available in ABAC | Yes | No (privacy) | | Use case | Access control | User preferences | -### 8.7 Endpoint Auth Modes +### 8.7 Entitlements and Scopes -**Status:** New in v6.0.0 - -**Purpose:** Per-endpoint control over whether authentication requires a user, an application (consumer), or both. +**Purpose:** Decide where a Role comes from on each request: a person's Entitlement, or an application's Scope. -**Overview:** +**How it works:** -Previously, a global `allow_entitlements_or_scopes` config flag allowed consumer scopes as an alternative to user entitlements for all endpoints. This has been replaced by a per-endpoint `authMode` field on `ResourceDoc`, providing fine-grained control. +Who is acting on the request decides, the same way on every endpoint: -**Auth Modes:** +| Who is acting | Where the Role must come from | +|---------------|-------------------------------| +| A person | An Entitlement held by the User | +| The application acting as itself | A Scope held by its Consumer | -| Mode | Description | -|------|-------------| -| `UserOnly` | (Default) Requires user authentication and user entitlements | -| `ApplicationOnly` | Requires consumer scope only, no user authentication needed | -| `UserXORApplication` | Consumer scope when the application acts as itself (no user, or its own client-credentials user, provider `obp-oidc#client`); user entitlement when a person acts. A person never uses the consumer's scopes. Formerly `UserOrApplication` (either was sufficient) | -| `UserAndApplication` | Both consumer scope AND user entitlement are required | +The application acts as itself only if both of these hold: -**Migration from `allow_entitlements_or_scopes`:** +1. Its Consumer is a Platform App. +2. It calls with no User, or as its own client-credentials User (provider `obp-oidc#client`, or `keycloak#client` for a Keycloak service account). -The global `allow_entitlements_or_scopes` config flag has been removed. Deployments that previously used `allow_entitlements_or_scopes=true` must now set `authMode = UserXORApplication` on individual endpoint `ResourceDoc` instances. +A Consumer that is not a Platform App can still hold Scopes. They then work only as a limit on people, through `require_scopes_for_all_roles` and `require_scopes_for_listed_roles`. With those props, a person needs the Entitlement and the Consumer they call through needs the Scope. -The global overrides `require_scopes_for_all_roles` and `require_scopes_for_listed_roles` continue to work and force `UserAndApplication` behavior, except for an application acting as itself on an `ApplicationOnly` or `UserXORApplication` endpoint, which still needs only its scopes. +**Who may call an endpoint at all** is read from its list of possible errors: -**Example:** +- `OBP-20001` (User not logged in), or a Role: a User is required. +- `OBP-20200` (The application cannot be identified) without `OBP-20001`: an identified application is required, and a User is optional. The Berlin Group and UK Open Banking consent and payment endpoints work like this. +- Neither: anyone may call. -The `verifyUserCredentials` endpoint uses `UserXORApplication` mode, so it can be called either by a logged-in user with the appropriate entitlement, or by an application acting as itself with the appropriate consumer scope. +**History:** v6.0.0 added a per-endpoint `authMode` (UserOnly, ApplicationOnly, UserXORApplication, UserAndApplication). It was removed in November 2026, because who is acting is a fact about each request, not about the endpoint. ### 8.8 Maker/Checker for Transaction Requests diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 5ae6962c19..0800cfb33f 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1434,14 +1434,15 @@ featured_apis=elasticSearchWarehouseV300 # -- Scopes ----------------------------------------------------- -# Scopes can be used to limit the APIs a Consumer can call. +# A Scope is a Role held by a Consumer (an application). +# An application acting as itself needs the Role as a Scope. It acts as itself only if its Consumer is a +# Platform App and it calls with no User or as its own client-credentials User. +# A person needs the Role as an Entitlement. With the props below, the Consumer they call through must +# also hold it as a Scope, so Scopes limit which Roles people can use through each application. -# In case isn't defined default value is "false" +# Default 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 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/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 87fb6da24b..d831fcf725 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -433,7 +433,10 @@ object OAuth2Login extends MdcLoggable { * @return an existing or a new user */ def getOrCreateResourceUser(jwtToken: String): Box[User] = { - val uniqueIdGivenByProvider = JwtUtil.getSubject(jwtToken).getOrElse("") + // A Keycloak service account's sub is a random id; its own user is named after the client id instead. + val uniqueIdGivenByProvider = + if (Keycloak.isServiceAccountToken(jwtToken)) getClaim(name = "azp", jwtToken = jwtToken).getOrElse("") + else JwtUtil.getSubject(jwtToken).getOrElse("") val provider = resolveProvider(jwtToken) // Only Keycloak issues the federated form. From any other issuer it would name an arbitrary user_id. val federatedRef = @@ -468,7 +471,8 @@ object OAuth2Login extends MdcLoggable { val providerFromToken = JwtUtil.getProvider(jwtToken) .filter(_ => OBPOIDC.isIssuer(jwtToken) || Keycloak.isIssuer(jwtToken)) - providerFromToken.filter(_.trim.nonEmpty) match { + if (Keycloak.isServiceAccountToken(jwtToken)) Keycloak.clientCredentialsProvider + else providerFromToken.filter(_.trim.nonEmpty) match { case Some(provider) => logger.debug(s"resolveProvider says: using provider from token claim: $provider") provider @@ -746,6 +750,14 @@ object OAuth2Login extends MdcLoggable { object Keycloak extends OAuth2Util { val keycloakHost = APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.host", "http://localhost:7070") + // The provider of the users that Keycloak's service-account (client-credentials) tokens resolve to: one no + // person can sign up under. Like OBP-OIDC's, each is named after its client id. + val clientCredentialsProvider = "keycloak#client" + /** A token Keycloak issued to a client's service account: preferred_username is "service-account-" + the client id. */ + def isServiceAccountToken(jwtToken: String): Boolean = isIssuer(jwtToken) && { + val clientId = getClaim(name = "azp", jwtToken = jwtToken).orElse(getClaim(name = "client_id", jwtToken = jwtToken)) + clientId.exists(id => id.nonEmpty && getClaim(name = "preferred_username", jwtToken = jwtToken).contains(s"service-account-$id")) + } /** * OpenID Connect Discovery. * Yahoo exposes OpenID Connect discovery documents ( https://YOUR_DOMAIN/.well-known/openid-configuration ). 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 5146eb8c3e..6927904f87 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 @@ -168,7 +168,8 @@ object OpenAPI31JSONFactory extends MdcLoggable { | |### Client Credentials Flow (2-legged / Application Only) | - |Use this flow for endpoints with authMode ApplicationOnly or UserXORApplication. + |Use this flow when the application acts as itself. Its Consumer must be a Platform App, and the Roles it + |needs must be granted to the Consumer as Scopes. |The application authenticates itself using its client_id and client_secret without a user login. | | POST $oidcTokenEndpoint diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index d39b8c0a82..a9a5421910 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -32,10 +32,10 @@ import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, UserXORApplication, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} +import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} +import code.api.util.ErrorMessages.{ApplicationNotIdentified, AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.CallContext import code.api.util.{Consent, ConsentJWT, JwtUtil, NewStyle} @@ -195,15 +195,11 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { "LastAvailableDateTime": "2020-10-20T08:40:47.375Z" } }"""), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Access") :: Nil, // Consent lodging is a client-credentials call: the TPP is authenticated as an app, and there - // is no PSU yet. The handler above already says so; this makes the ResourceDoc say it too. - // Without it the doc defaults to UserOnly, which sends the middleware down anonymousAccess and - // 401s any request that carries no user -- so the endpoint only works today because OAuth2 - // token parsing auto-vivifies a user for a client-credentials token. Matches the Berlin Group - // twin (Http4sBGv13AIS.createConsent), which has always been UserXORApplication. - authMode = UserXORApplication, + // is no PSU yet. The error list says so: ApplicationNotIdentified without AuthenticatedUserIsRequired + // means an identified application is required and a user is optional (requiresApplicationButNotUser). http4sPartialFunction = Some(createAccountAccessConsents) ) @@ -236,11 +232,10 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { |""".stripMargin, EmptyBody, EmptyBody, - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Access") :: Nil, // As with the POST that lodges the consent: revoking is a client-credentials call in the // standard's AISP flow, so a PSU cannot be required. See Consent.checkUKConsentAccess. - authMode = UserXORApplication, http4sPartialFunction = Some(deleteAccountAccessConsentsConsentId) ) @@ -315,10 +310,9 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { "LastAvailableDateTime": "2020-10-20T10:28:39.801Z" } }"""), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Access") :: Nil, // As above: the AISP polls its own consent with a client-credentials token. - authMode = UserXORApplication, http4sPartialFunction = Some(getAccountAccessConsentsConsentId) ) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index aa7be7bf8d..dca5c0df86 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -34,11 +34,11 @@ import cats.effect.IO import code.api.APIFailureNewStyle import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, HTTPParam, UserXORApplication, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, parseIso8601OrDayDate} +import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, HTTPParam, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} +import code.api.util.ErrorMessages.{ApplicationNotIdentified, AuthenticatedUserIsRequired, BankNotFound, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle import code.api.util.{APIUtil, Consent, ConsentJWT, JwtUtil, NewStyle} @@ -203,15 +203,11 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { """Enables an AISP to ask an ASPSP to create a new account-access-consent resource, by sending a copy of the consent to the ASPSP.""", parseBody(EXREQ_createAccountAccessConsents), parseBody(EX_createAccountAccessConsents), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Access Consents") :: Nil, // Consent lodging is a client-credentials call: the TPP is authenticated as an app, and there - // is no PSU yet. The handler above already says so; this makes the ResourceDoc say it too. - // Without it the doc defaults to UserOnly, which sends the middleware down anonymousAccess and - // 401s any request that carries no user -- so the endpoint only works today because OAuth2 - // token parsing auto-vivifies a user for a client-credentials token. Matches the Berlin Group - // twin (Http4sBGv13AIS.createConsent), which has always been UserXORApplication. - authMode = UserXORApplication, + // is no PSU yet. The error list says so: ApplicationNotIdentified without AuthenticatedUserIsRequired + // means an identified application is required and a user is optional (requiresApplicationButNotUser). http4sPartialFunction = Some(createAccountAccessConsents) ) @@ -287,11 +283,10 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { """Enables an AISP to retrieve the status of an AIS consent.""", EmptyBody, parseBody(EX_getAccountAccessConsentsConsentId), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Access Consents") :: Nil, // Same reasoning as the POST that lodges the consent: the AISP polls it with a // client-credentials token, so a PSU cannot be required. See Consent.checkUKConsentAccess. - authMode = UserXORApplication, http4sPartialFunction = Some(getAccountAccessConsentsConsentId) ) @@ -321,10 +316,9 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { """Enables an AISP to inform the ASPSP that the PSU has revoked their consent.""", EmptyBody, parseBody(EX_deleteAccountAccessConsentsConsentId), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Access Consents") :: Nil, // As above: revoking is a client-credentials call in the standard's AISP flow. - authMode = UserXORApplication, http4sPartialFunction = Some(deleteAccountAccessConsentsConsentId) ) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala index 02add13773..44ebda969f 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala @@ -38,7 +38,7 @@ import code.api.berlin.group.v1_3.model._ import code.api.berlin.group.v1_3.{BgSpecValidation, JSONFactory_BERLIN_GROUP_1_3, JvalueCaseClass} import code.api.RequestHeader import code.api.util.APIUtil -import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, UserXORApplication, connectorEmptyResponse, createQueriesByHttpParams, fullBoxOrException, getHttpRequestUrlParam, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, unboxFull, unboxFullOrFail} +import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, connectorEmptyResponse, createQueriesByHttpParams, fullBoxOrException, getHttpRequestUrlParam, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, unboxFull, unboxFullOrFail} import code.api.util.CallContext import code.api.util.ApiTag._ import code.api.util.CustomJsonFormats @@ -846,9 +846,8 @@ recurringIndicator: consentStatus = "received", _links = ConsentLinksV13(Some(Href("/v1.3/consents/1234-wertiq-983/authorisations"))) ), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(createConsent) ) @@ -862,9 +861,8 @@ recurringIndicator: The TPP can delete an account information consent object if needed.""", EmptyBody, EmptyBody, - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(deleteConsent) ) @@ -900,9 +898,8 @@ where the consent was directly managed between ASPSP and PSU e.g. in a re-direct "lastActionDate": "2019-06-30", "consentStatus": "received" }""")), - List(AuthenticatedUserIsRequired, ConsentNotFound, UnknownError), + List(ApplicationNotIdentified, ConsentNotFound, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(getConsentInformation) ) @@ -918,9 +915,8 @@ where the consent was directly managed between ASPSP and PSU e.g. in a re-direct JvalueCaseClass(json.parse("""{ "consentStatus": "received" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(getConsentStatus) ) @@ -973,15 +969,13 @@ using the extended forms as indicated above. generalStartConsentAuthorisationSummary, JvalueCaseClass(json.parse("""{"scaAuthenticationData":""}""")), startConsentAuthorisationResponse, - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, // Berlin Group's Embedded SCA step, and the TPP is the caller: the PSU either authenticated // at the ASPSP under Redirect or handed its factors to the TPP under Embedded. Which PSU the // challenge is for no longer comes from the session -- see Consent.resolveBerlinGroupPsu -- - // so the doc can now say what the call actually is, as its GET siblings already do. Left on - // the UserOnly default it would 401 a client-credentials caller the day OAuth2 token parsing - // stops auto-vivifying a user. - authMode = UserXORApplication, + // so the doc can now say what the call actually is, as its GET siblings already do: its error + // list names ApplicationNotIdentified, not AuthenticatedUserIsRequired, so no user is needed. http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -994,9 +988,8 @@ using the extended forms as indicated above. generalStartConsentAuthorisationSummary, JvalueCaseClass(json.parse("""{"psuData": {"password": "start12"}}""")), startConsentAuthorisationResponse, - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -1009,9 +1002,8 @@ using the extended forms as indicated above. generalStartConsentAuthorisationSummary, JvalueCaseClass(json.parse("""{"authenticationMethodId":""}""")), startConsentAuthorisationResponse, - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -1056,9 +1048,8 @@ Maybe in a later version the access path will change. scaStatus = "received", _links = Some(LinksAll(scaStatus = Some(HrefType(Some(s"/v1.3/consents/1234-wertiq-983/authorisations"))))) ), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -1076,9 +1067,8 @@ Maybe in a later version the access path will change. | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} | } | }""".stripMargin)), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -1104,9 +1094,8 @@ Maybe in a later version the access path will change. | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} | } | }""".stripMargin)), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -1124,9 +1113,8 @@ Maybe in a later version the access path will change. | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} | } | }""".stripMargin)), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) } @@ -1337,7 +1325,7 @@ This function returns an array of hyperlinks to all generated authorisation sub- JvalueCaseClass(json.parse("""{ "authorisationIds" : "faa3657e-13f0-4feb-a6c3-34bf21a9ae8e" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, // The TPP reads its own consent's authorisation sub-resources; no PSU is party to the call. // The Implementation Guidelines put it plainly (§4.6, Authorisation Endpoints): the ASPSP @@ -1345,11 +1333,9 @@ This function returns an array of hyperlinks to all generated authorisation sub- // and "the authorisation status would still result by submitting the command GET // .../authorisations/authorisationId". In Berlin Group the PSU never calls the API at all -- // it authenticates at the ASPSP under Redirect, or hands its factors to the TPP under - // Embedded. The handler already reflects that, taking no user; only the doc disagreed, and a - // doc left on the UserOnly default sends the middleware down anonymousAccess, which 401s a - // request carrying no user. Brings these into line with the consent endpoints in this same - // file, which have been UserXORApplication all along. - authMode = UserXORApplication, + // Embedded. The handler already reflects that, taking no user, and so does the doc: its error list + // names ApplicationNotIdentified, not AuthenticatedUserIsRequired, like the consent endpoints + // in this same file. http4sPartialFunction = Some(getConsentAuthorisation) ) @@ -1366,10 +1352,9 @@ This method returns the SCA status of a consent initiation's authorisation sub-r JvalueCaseClass(json.parse("""{ "scaStatus" : "started" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, // As above -- reading the SCA status is the TPP polling its own authorisation sub-resource. - authMode = UserXORApplication, http4sPartialFunction = Some(getConsentScaStatus) ) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala index d92447d610..63e64a458b 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIIS.scala @@ -69,7 +69,7 @@ object Http4sBGv13PIIS extends MdcLoggable { // ── POST /funds-confirmations ───────────────────────────────────── // Lift source: code.api.builder.ConfirmationOfFundsServicePIISApi.checkAvailabilityOfFunds - // Auth: authenticatedAccess → authMode UserOnly (default); user resolved by ResourceDocMiddleware. + // Auth: authenticatedAccess → AuthenticatedUserIsRequired in the error list; user resolved by ResourceDocMiddleware. val checkAvailabilityOfFunds: Http4sRoute = Http4sRoute { case req @ POST -> `bgV13Prefix` / "funds-confirmations" => EndpointHelpers.executeAndRespond(req) { cc => diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala index b84e35d1ff..3e85ca0c76 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala @@ -34,7 +34,7 @@ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ import code.api.berlin.group.v1_3.model.TransactionStatus.mapTransactionStatus import code.api.berlin.group.v1_3.model._ -import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, UserXORApplication, getScaMethodAtInstance, getServerUrl, isValidCurrencyISOCode, mockedDataText, passesPsd2Pisp} +import code.api.util.APIUtil.{Http4sHandler, Http4sRoute, EmptyBody, ResourceDoc, getScaMethodAtInstance, getServerUrl, isValidCurrencyISOCode, mockedDataText, passesPsd2Pisp} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats @@ -143,7 +143,7 @@ object Http4sBGv13PIS extends MdcLoggable { /** * Shared business logic for all three initiate-payment variants (payments / periodic-payments / * bulk-payments). Mirrors `initiatePaymentImplementation` from the Lift builder; auth is handled - * by middleware (authMode = UserXORApplication), so no inline applicationAccess call. + * by middleware (the error list names ApplicationNotIdentified), so no inline applicationAccess call. */ private def initiatePaymentImpl( paymentService: String, @@ -427,7 +427,7 @@ object Http4sBGv13PIS extends MdcLoggable { } // ── POST /payments/{paymentProduct} ────────────────────────────────────────────────────────────── - // Auth: applicationAccess in Lift → authMode = UserXORApplication in ResourceDoc + // Auth: applicationAccess in Lift → ApplicationNotIdentified in the ResourceDoc's error list lazy val initiatePayments: Http4sRoute = Http4sRoute { case req @ POST -> `bgV13Prefix` / "payments" / paymentProduct => EndpointHelpers.executeFutureCreated(req) { @@ -1040,9 +1040,8 @@ Check the transaction status of a payment initiation.""", $generalPaymentSummaryText""", initiatePaymentRequestBody, initiatePaymentResponseBody, - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(initiatePayments) ) @@ -1073,9 +1072,8 @@ $generalPaymentSummaryText""", "scaStatus": {"href": "/v1.3/periodic-payments/1234-wertiq-983/authorisations/123auth456"} } }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(initiatePeriodicPayments) ) @@ -1109,9 +1107,8 @@ $generalPaymentSummaryText""", "scaStatus": {"href": "/v1.3/bulk-payments/1234-wertiq-983/authorisations/123auth456"} } }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(ApplicationNotIdentified, UnknownError), ApiTag("Payment Initiation Service (PIS)") :: apiTagBerlinGroupM :: Nil, - authMode = UserXORApplication, http4sPartialFunction = Some(initiateBulkPayments) ) } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index 1eefbaec02..a170a55aab 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -332,20 +332,10 @@ object Http4sDynamicEntity extends MdcLoggable { // write role is sufficient to PATCH that field alone. Returns the distinct role names the caller is missing. // For a personal entity that doesn't require a role, the entity update role on unrestricted fields is skipped, // but write-restricted fields are still gated by their field write role. - // ---- Auth mode (who may hold the entity's roles) ------------------------------------------ - // The entity definition's authMode decides whether the data endpoints accept a User's Entitlements, - // a Consumer's Scopes, either, or both. Personal ("my") endpoints and row-level (ACL) entities - // always need a User: their rows belong to one. - private def authModeOf(bankId: Option[String], entityName: String): EndpointAuthMode = - DynamicEntityHelper.definitionOf(bankId, entityName).map(_.endpointAuthMode).getOrElse(UserOnly) - - /** authenticatedAccess for user modes, applicationAccess (User optional, Consumer required) for application modes. */ - private def entityAccess(cc: CallContext, bankId: Option[String], entityName: String, isPersonalEntity: Boolean): Future[(Box[User], Option[CallContext])] = - if (isPersonalEntity || isRowLevel(bankId, entityName)) authenticatedAccess(cc) - else authModeOf(bankId, entityName) match { - case ApplicationOnly | UserXORApplication => applicationAccess(cc) - case _ => authenticatedAccess(cc) - } + // ---- Who may call --------------------------------------------------------------------------- + // An entity's data endpoints work like static endpoints with Roles: a user is required. A person needs the + // entity's Role as an Entitlement. A Platform App acting as itself calls as its own client-credentials user + // and needs the Role as a Scope (APIUtil.handleAccessControlRegardingEntitlementsAndScopes). /** * Personal ("my") endpoints and consent users. A consent user's rows resolve to the User its @@ -369,14 +359,14 @@ object Http4sDynamicEntity extends MdcLoggable { if (isPersonalEntity) userIdOpt.map(id => code.users.Users.users.vend.attributedUserId(id, code.users.UserReference.DynamicData_UserId).openOr(id)) else userIdOpt - /** The entity's role, checked per the entity's auth mode (entitlements, scopes, either or both). */ + /** The entity's role: an Entitlement for a person, a Scope for a Platform App acting as itself. */ private def checkEntityRole(bankId: Option[String], entityName: String, boxUser: Box[User], role: ApiRole, callContext: Option[CallContext]): Future[Box[Unit]] = { val bankIdStr = DynamicEntitySpace.bankIdOrSystem(bankId) val userId = boxUser.map(_.userId).openOr("") val consumerId = code.api.util.APIUtil.getConsumerPrimaryKey(callContext) val errorMessage = UserHasMissingRoles + role.toString + s" at Bank($bankIdStr)" Helper.booleanToFuture(errorMessage, cc = callContext) { - code.api.util.APIUtil.handleAccessControlWithAuthMode(bankIdStr, userId, consumerId, List(role), authModeOf(bankId, entityName)) + code.api.util.APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankIdStr, userId, consumerId, List(role)) } } @@ -388,9 +378,8 @@ object Http4sDynamicEntity extends MdcLoggable { val schemaFields = info.map(_.propertyNames).getOrElse(bodyFieldNames) val touched = bodyFieldNames.intersect(schemaFields) val writeRestricted = info.map(_.writeRestrictedFields).getOrElse(Nil).toSet - val authMode = authModeOf(bankId, entityName) def has(role: code.api.util.ApiRole): Boolean = - code.api.util.APIUtil.handleAccessControlWithAuthMode(DynamicEntitySpace.bankIdOrSystem(bankId), userId, consumerId, List(role), authMode) + code.api.util.APIUtil.handleAccessControlRegardingEntitlementsAndScopes(DynamicEntitySpace.bankIdOrSystem(bankId), userId, consumerId, List(role)) touched.flatMap { f => if (writeRestricted.contains(f)) { val role = DynamicEntityInfo.fieldWriteRole(entityName, f, bankId, info.flatMap(_.explicitWriteRole(f))) @@ -680,7 +669,7 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + (boxUser, callContext) <- authenticatedAccess(callContext0) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) @@ -725,7 +714,7 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + (boxUser, callContext) <- authenticatedAccess(callContext0) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) @@ -759,7 +748,7 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + (boxUser, callContext) <- authenticatedAccess(callContext0) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) @@ -788,7 +777,7 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + (boxUser, callContext) <- authenticatedAccess(callContext0) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) @@ -822,7 +811,7 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + (boxUser, callContext) <- authenticatedAccess(callContext0) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 262d5809ce..7710e6ca8a 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -242,7 +242,7 @@ object DynamicEntityHelper { /** The number of definitions in this instance's kept map, for the cache info page. */ def keptDefinitionCount: Int = definitionsMapCache.kept.map(_.size).getOrElse(0) - private def buildDefinitionsMap: Map[(String, String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((DynamicEntitySpace.bankIdOrSystem(it.bankId), it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess, it.authMode))).toMap + private def buildDefinitionsMap: Map[(String, String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((DynamicEntitySpace.bankIdOrSystem(it.bankId), it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess))).toMap /** * The definition of one entity in one space, or None when that space holds no such entity. @@ -490,8 +490,7 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canGetRole)), - createdByBankId= dynamicEntityInfo.bankId, - authMode = dynamicEntityInfo.endpointAuthMode + createdByBankId= dynamicEntityInfo.bankId ) resourceDocs += (DynamicEntityOperation.GET_ONE, splitNameWithBankId) -> ResourceDoc( @@ -518,8 +517,7 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canGetRole)), - createdByBankId= dynamicEntityInfo.bankId, - authMode = dynamicEntityInfo.endpointAuthMode + createdByBankId= dynamicEntityInfo.bankId ) resourceDocs += (DynamicEntityOperation.CREATE, splitNameWithBankId) -> ResourceDoc( @@ -548,8 +546,7 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canCreateRole)), - createdByBankId= dynamicEntityInfo.bankId, - authMode = dynamicEntityInfo.endpointAuthMode + createdByBankId= dynamicEntityInfo.bankId ) resourceDocs += (DynamicEntityOperation.UPDATE, splitNameWithBankId) -> ResourceDoc( @@ -578,8 +575,7 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canUpdateRole)), - createdByBankId= dynamicEntityInfo.bankId, - authMode = dynamicEntityInfo.endpointAuthMode + createdByBankId= dynamicEntityInfo.bankId ) resourceDocs += (DynamicEntityOperation.PATCH, splitNameWithBankId) -> ResourceDoc( @@ -615,8 +611,7 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canUpdateRole)), - createdByBankId= dynamicEntityInfo.bankId, - authMode = dynamicEntityInfo.endpointAuthMode + createdByBankId= dynamicEntityInfo.bankId ) resourceDocs += (DynamicEntityOperation.DELETE, splitNameWithBankId) -> ResourceDoc( @@ -642,8 +637,7 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canDeleteRole)), - createdByBankId= dynamicEntityInfo.bankId, - authMode = dynamicEntityInfo.endpointAuthMode + createdByBankId= dynamicEntityInfo.bankId ) if(hasPersonalEntity){ //only hasPersonalEntity == true, then create the myEndpoints @@ -1086,16 +1080,8 @@ object DynamicEntityHelper { |""".stripMargin } -case class DynamicEntityInfo(definition: String, entityName: String, bankId: Option[String], hasPersonalEntity: Boolean, hasPublicAccess: Boolean = false, hasCommunityAccess: Boolean = false, personalRequiresRole: Boolean = false, useRowLevelAccess: Boolean = false, authMode: String = code.dynamicEntity.DynamicEntityAuthMode.default) { - - /** The entity's auth mode as the framework type; unknown or empty values read as UserOnly. */ - val endpointAuthMode: code.api.util.APIUtil.EndpointAuthMode = authMode match { - case code.dynamicEntity.DynamicEntityAuthMode.ApplicationOnly => code.api.util.APIUtil.ApplicationOnly - case code.dynamicEntity.DynamicEntityAuthMode.UserXORApplication | code.dynamicEntity.DynamicEntityAuthMode.LegacyUserOrApplication => - code.api.util.APIUtil.UserXORApplication - case code.dynamicEntity.DynamicEntityAuthMode.UserAndApplication => code.api.util.APIUtil.UserAndApplication - case _ => code.api.util.APIUtil.UserOnly - } +case class DynamicEntityInfo(definition: String, entityName: String, bankId: Option[String], hasPersonalEntity: Boolean, hasPublicAccess: Boolean = false, hasCommunityAccess: Boolean = false, personalRequiresRole: Boolean = false, useRowLevelAccess: Boolean = false) { + import com.openbankproject.commons.util.json import code.api.dynamic.entity.query.FieldSpec @@ -1438,15 +1424,15 @@ object DynamicEntityInfo { */ /** * True when a caller's access to an entity comes only from its public access: the entity has public - * access, the caller does not hold its read Role (judged by its authentication mode), and the caller is + * access, the caller does not hold its read Role, and the caller is * not reaching it through a row-level access list (an anonymous caller never is). A field declared * `hide_field_from_public_access` is hidden from such a caller. */ def readsViaPublicAccess(bankId: Option[String], entityName: String, userIdOpt: Option[String], consumerId: String): Boolean = DynamicEntityHelper.definitionOf(bankId, entityName).exists { info => info.hasPublicAccess && !(info.useRowLevelAccess && userIdOpt.isDefined) && - !APIUtil.handleAccessControlWithAuthMode(DynamicEntitySpace.bankIdOrSystem(bankId), userIdOpt.getOrElse(""), consumerId, - List(canGetRole(entityName, bankId)), info.endpointAuthMode) + !APIUtil.handleAccessControlRegardingEntitlementsAndScopes(DynamicEntitySpace.bankIdOrSystem(bankId), userIdOpt.getOrElse(""), consumerId, + List(canGetRole(entityName, bankId))) } /** @@ -1470,8 +1456,8 @@ object DynamicEntityInfo { def mayReadRecords(bankId: Option[String], entityName: String, userIdOpt: Option[String], consumerId: String): Boolean = DynamicEntityHelper.definitionOf(bankId, entityName).exists { info => info.hasPublicAccess || info.useRowLevelAccess || - APIUtil.handleAccessControlWithAuthMode(DynamicEntitySpace.bankIdOrSystem(bankId), userIdOpt.getOrElse(""), consumerId, - List(canGetRole(entityName, bankId)), info.endpointAuthMode) + APIUtil.handleAccessControlRegardingEntitlementsAndScopes(DynamicEntitySpace.bankIdOrSystem(bankId), userIdOpt.getOrElse(""), consumerId, + List(canGetRole(entityName, bankId))) } def mayReadField(bankId: Option[String], entityName: String, fieldName: String, userIdOpt: Option[String], diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala index ede415eeb8..d2d6d0bc38 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala @@ -27,7 +27,6 @@ TESOBE (http://www.tesobe.com/) package code.api.dynamic.entity.helper import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID -import code.api.util.APIUtil.{EndpointAuthMode, UserOnly} import code.api.util.ErrorMessages.UserHasMissingRoles import code.api.util.{APIUtil, ApiRole, CallContext} import code.util.Helper @@ -66,14 +65,13 @@ object DynamicEntitySpace { * URL, so the middleware would check their Role at the empty bank id, where no Definition Role is * granted any more. Those endpoints declare the Role with disableAutoValidateRoles(), so it still * shows in the catalogue, and call this instead. It answers exactly as the middleware would have: - * the same access rule, the same 403 and the same message. `authMode` is the endpoint's own, as its - * ResourceDoc declares it. + * the same access rule, the same 403 and the same message. */ - def requireRoleAtSystemSpace(role: ApiRole, cc: CallContext, authMode: EndpointAuthMode = UserOnly): Future[Box[Unit]] = { + def requireRoleAtSystemSpace(role: ApiRole, cc: CallContext): Future[Box[Unit]] = { val userId = cc.user.map(_.userId).openOr("") Helper.booleanToFuture(UserHasMissingRoles + role.toString, 403, Some(cc)) { - APIUtil.handleAccessControlWithAuthMode( - DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, userId, APIUtil.getConsumerPrimaryKey(Some(cc)), List(role), authMode) + APIUtil.handleAccessControlRegardingEntitlementsAndScopes( + DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, userId, APIUtil.getConsumerPrimaryKey(Some(cc)), List(role)) } } } 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 5c52fce45a..d5530a829c 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1584,14 +1584,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ .filter(isPathVariable(_)) } - sealed trait EndpointAuthMode - case object UserOnly extends EndpointAuthMode - case object ApplicationOnly extends EndpointAuthMode - /** Either a person or the application acting as itself, never a mix: when the application acts as itself - * (see isApplicationActingAsItself) only its Consumer's Scopes count; otherwise only the User's Entitlements. */ - case object UserXORApplication extends EndpointAuthMode - case object UserAndApplication extends EndpointAuthMode - // Used to document the API calls case class ResourceDoc( implementedInApiVersion: ScannedApiVersion, // TODO: Use ApiVersion enumeration instead of string @@ -1624,7 +1616,6 @@ 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, UserXORApplication, UserAndApplication http4sPartialFunction: Http4sEndpoint = None, // http4s endpoint handler // Native http4s handler for runtime-compiled dynamic endpoints (Piece C). Defaulted to None so // no existing construction site changes. Set by DynamicResourceDocsEndpointGroup / practise group @@ -1662,26 +1653,15 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ |$authenticationIsOptional |""" } - - // Auth-mode-aware error message adjustments - authMode match { - case ApplicationOnly | UserXORApplication => - errorResponseBodies ?+= ApplicationNotIdentified - if (authMode == ApplicationOnly) { - errorResponseBodies ?-= AuthenticatedUserIsRequired - } - if (authMode == UserXORApplication) { - description += - s""" - | - |This endpoint supports **User XOR Application** authorisation: either a person acting on their own Entitlements, or an Application acting as itself (e.g. with an OAuth2 client-credentials token) on its Consumer's Scopes. A person calling through a Consumer does not get that Consumer's Scopes. - |See ${Glossary.getGlossaryItemLink("API.Endpoint Auth Modes")} for more information. - |""" - } - case UserAndApplication => - errorResponseBodies ?+= AuthenticatedUserIsRequired - errorResponseBodies ?+= ApplicationNotIdentified - case UserOnly => // existing logic already handles this + // A user is optional but the application must be identified: say so (see requiresApplicationButNotUser) + val applicationIsRequired = applicationAccessMessage(true) + if (errorResponseBodies.contains(ApplicationNotIdentified) && !errorResponseBodies.contains(AuthenticatedUserIsRequired) + && !description.contains(applicationIsRequired)) { + description += + s""" + | + |$applicationIsRequired + |""" } } @@ -1789,6 +1769,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ private val requestUrlPartPath: Array[String] = StringUtils.split(requestUrl, '/') private val AuthCheckIsRequired = errorResponseBodies.contains($AuthenticatedUserIsRequired) + + /** The error list says who may call. "User not logged in" or Roles: a user is required. + * "The application cannot be identified" without those: an identified application is required, and a user is + * optional, as for a payment provider creating a consent. Neither: anyone may call. */ + def requiresApplicationButNotUser: Boolean = + errorResponseBodies.contains(ApplicationNotIdentified) && + !errorResponseBodies.contains($AuthenticatedUserIsRequired) && roles.forall(_.isEmpty) private val isNeedCheckRoles = _autoValidateRoles && rolesForCheck.nonEmpty private val isNeedCheckBank = errorResponseBodies.contains($BankNotFound) && requestUrlPartPath.contains("BANK_ID") private val isNeedCheckAccount = errorResponseBodies.contains($BankAccountNotFound) && @@ -1840,10 +1827,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val viewId = pathParams.get("VIEW_ID").map(ViewId(_)) val counterpartyId = pathParams.get("COUNTERPARTY_ID").map(CounterpartyId(_)) - def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = authMode match { - case UserOnly | UserAndApplication => if (AuthCheckIsRequired) authenticatedAccessFun(cc) else anonymousAccessFun(cc) - case ApplicationOnly | UserXORApplication => applicationAccessFun(cc) - } + def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = + if (requiresApplicationButNotUser) applicationAccessFun(cc) + else if (AuthCheckIsRequired) authenticatedAccessFun(cc) + else anonymousAccessFun(cc) def checkObpIds(pairs: List[(String, String)], callContext: Option[CallContext]): Future[Option[CallContext]] = Future { val invalid = pairs.filter(p => !checkObpId(p._2).equals(SILENCE_IS_GOLDEN)) if (invalid.nonEmpty) throw new RuntimeException(s"$InvalidJsonFormat Here are all invalid values: $invalid") else callContext @@ -1857,7 +1844,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val consumerId = APIUtil.getConsumerPrimaryKey(callContext) val errorMessage = if (rolesForCheck.filter(_.requiresBankId).isEmpty) UserHasMissingRoles + rolesForCheck.mkString(" or ") else UserHasMissingRoles + rolesForCheck.mkString(" or ") + s" for BankId($bankIdStr)." - Helper.booleanToFuture(errorMessage, cc = callContext) { APIUtil.handleAccessControlWithAuthMode(bankIdStr, userIdStr, consumerId, rolesForCheck, authMode) } + Helper.booleanToFuture(errorMessage, cc = callContext) { APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankIdStr, userIdStr, consumerId, rolesForCheck) } } else Future.successful(Full(())) def checkAccount(bankId: Option[BankId], accountId: Option[AccountId], callContext: Option[CallContext]): Future[(BankAccount, Option[CallContext])] = if (isNeedCheckAccount && bankId.isDefined && accountId.isDefined) checkAccountFun(bankId.get)(accountId.get, callContext) else Future.successful(null.asInstanceOf[BankAccount] -> callContext) @@ -2166,27 +2153,25 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def hasScope(bankId: String, consumerId: String, role: ApiRole): Boolean = { !Scope.scope.vend.getScope(bankId, consumerId, role.toString).isEmpty } - /** True when no person is behind the call: there is no User, or the User is the application's own, the one an - * OBP-OIDC client-credentials token resolves to (provider obp-oidc#client, named after the Consumer's client id). - * consumerId is the Consumer's primary key, as from getConsumerPrimaryKey. */ - def isApplicationActingAsItself(userId: String, consumerId: String): Boolean = { - lazy val consumer = tryo(consumerId.toLong).flatMap(Consumers.consumers.vend.getConsumerByPrimaryId) - // If the Consumer has grant types set, its grant types decide: - // - client_credentials only: every call through the Consumer is the application acting as itself. - // (A person's call through such a Consumer has already been refused in AfterApiAuth.) - // - client_credentials not included: no call through the Consumer is the application acting as itself. - // If the Consumer has no grant types set, the rules below apply as before. - if (consumer.exists(code.consumer.ConsumerGrantTypes.isClientCredentialsOnly)) true - else if (consumer.exists(code.consumer.ConsumerGrantTypes.excludesClientCredentials)) false - else if (userId.isEmpty) consumerId.nonEmpty - else (for { - user <- Users.users.vend.getUserByUserId(userId) - c <- consumer - } yield isApplicationsOwnUser(user, c)).openOr(false) - } - + /** True when the application is acting as itself, so its Consumer's Scopes count instead of a person's + * Entitlements. Its Consumer must be a Platform App, and the call must come with no user or as the application's + * own user. consumerId is the Consumer's primary key, as from getConsumerPrimaryKey. */ + def isApplicationActingAsItself(userId: String, consumerId: String): Boolean = + (for { + primaryKey <- tryo(consumerId.toLong) + consumer <- Consumers.consumers.vend.getConsumerByPrimaryId(primaryKey) + if isPlatformApp(consumer) + } yield userId.isEmpty || Users.users.vend.getUserByUserId(userId).exists(isApplicationsOwnUser(_, consumer))).openOr(false) + + /** Only a Platform App may use its Consumer's Scopes by itself. Marking grants no Scopes: an administrator + * grants them, after the application has declared what it needs. See the Glossary item "Platform Apps". */ + def isPlatformApp(consumer: code.model.Consumer): Boolean = + code.platformapp.PlatformApps.platformAppProvider.vend.getPlatformApp(consumer.consumerId.get).isDefined + + /** The application's own user: the one its client-credentials token resolves to, at OBP-OIDC + * (provider obp-oidc#client) or at Keycloak (provider keycloak#client), named after its client id. */ def isApplicationsOwnUser(user: User, consumer: code.model.Consumer): Boolean = - user.provider == code.api.OAuth2Login.OBPOIDC.clientCredentialsProvider && + Set(code.api.OAuth2Login.OBPOIDC.clientCredentialsProvider, code.api.OAuth2Login.Keycloak.clientCredentialsProvider).contains(user.provider) && Set(Option(consumer.key.get), Option(consumer.azp.get)).flatten.filter(_.nonEmpty).contains(user.idGivenByProvider) def getConsumerPrimaryKey(callContext: Option[CallContext]): String = { @@ -2380,94 +2365,35 @@ 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 - } 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 { - val requireScopesForListedRoles = getPropsValue("require_scopes_for_listed_roles", "").split(",").toSet - val requireScopesForRoles = roles.map(_.toString).toSet.intersect(requireScopesForListedRoles) - - def userHasTheRoles: Boolean = { - val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) - userHasTheRole || grantJustInTimeEntitlements(bankId, userId, roles) - } - - // Consumer AND User has the Role - if (ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty) { - userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _)) - } - // User has the Role - else { - userHasTheRoles - } - } // end of virtual roles else - } - } - - - /** - * 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 - * - UserXORApplication: consumer scopes when the application acts as itself, otherwise user entitlements - * - UserAndApplication: scopes AND entitlements - * Global overrides (require_scopes_for_all_roles, require_scopes_for_listed_roles) force UserAndApplication behavior, - * except for an application acting as itself on an ApplicationOnly or UserXORApplication endpoint: it has no - * Entitlements to add, so it keeps needing only its Scopes. + * Whether the caller holds at least one of the Roles. An empty list means no Role is needed. + * + * Who is acting decides where the Role comes from: + * - Virtual Roles from config (super_admin_user_ids, oidc_operator_user_ids) always count. + * - An application acting as itself (see isApplicationActingAsItself) needs the Role as a Scope on its Consumer. + * - Anyone else is a person, who needs the Role as an Entitlement (just-in-time Entitlements count). If + * require_scopes_for_all_roles is set, or require_scopes_for_listed_roles lists the Role, the Consumer they + * call through must also hold it as a Scope. */ - def handleAccessControlWithAuthMode(bankId: String, userId: String, consumerId: String, roles: List[ApiRole], authMode: EndpointAuthMode): Boolean = { + def handleAccessControlRegardingEntitlementsAndScopes(bankId: String, userId: String, consumerId: String, roles: List[ApiRole]): 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 || grantJustInTimeEntitlements(bankId, userId, roles) - } - - def consumerHasTheScopes: Boolean = - roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) - - lazy val applicationActsAsItself = isApplicationActingAsItself(userId, consumerId) - - if (globalOverrideToUserAndApp) { - authMode match { - case ApplicationOnly | UserXORApplication if applicationActsAsItself => consumerHasTheScopes - // Global config forces both scopes AND entitlements - case _ => userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _)) - } - } else { - authMode match { - case UserOnly => userHasTheRoles - case ApplicationOnly => consumerHasTheScopes - case UserXORApplication => if (applicationActsAsItself) consumerHasTheScopes else userHasTheRoles - case UserAndApplication => userHasTheRoles && consumerHasTheScopes - } - } + def consumerHasTheScopes: Boolean = + roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) + def userHasTheRoles: Boolean = + roles.exists(hasEntitlement(bankId, userId, _)) || grantJustInTimeEntitlements(bankId, userId, roles) + def scopesAreRequiredForPeople: Boolean = { + val listed = getPropsValue("require_scopes_for_listed_roles", "").split(",").map(_.trim).filter(_.nonEmpty).toSet + ApiPropsWithAlias.requireScopesForAllRoles || roles.exists(role => listed.contains(role.toString)) } + if (roles.exists(role => virtualRoles.contains(role.toString))) true + else if (isApplicationActingAsItself(userId, consumerId)) consumerHasTheScopes + else if (scopesAreRequiredForPeople) userHasTheRoles && consumerHasTheScopes + else userHasTheRoles } } @@ -3672,21 +3598,19 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ /** * Account access granted to a Consumer (code.consumeraccountaccess): true when the caller is an application - * acting as itself, its Consumer holds a grant for this view of this account, and the endpoint opts in. + * acting as itself and its Consumer holds a grant for this view of this account. * * - Acting as itself means the caller is the Consumer's own client-credentials User (provider obp-oidc#client, * named after the Consumer's key). A person calling through the same Consumer never picks up its grants, * and neither does a caller identified by a Consumer Key alone: the token proves the client secret. - * - The endpoint opts in through its auth mode: only ApplicationOnly and UserXORApplication endpoints admit - * an application acting as itself, so every other endpoint of the view keeps needing a User's access. + * - The Consumer must be a Platform App. The grant then works like a person's access to the view, on every + * endpoint that uses the view. */ def hasConsumerAccountAccess(view: View, bankIdAccountId: BankIdAccountId, user: User, callContext: Option[CallContext]): Boolean = (for { cc <- callContext - doc <- cc.resourceDocument - if doc.authMode == ApplicationOnly || doc.authMode == UserXORApplication consumer <- cc.consumer.toOption - if isApplicationsOwnUser(user, consumer) + if isPlatformApp(consumer) && isApplicationsOwnUser(user, consumer) } yield code.consumeraccountaccess.ConsumerAccountAccesses.consumerAccountAccessProvider.vend .find(consumer.consumerId.get, bankIdAccountId.bankId, bankIdAccountId.accountId, view.viewId) .isDefined diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 8888ceec9e..1344a96f79 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -130,7 +130,7 @@ case class CallContext( // request, but only the data-read endpoints run on the principal; the consent-management // endpoints (GET/DELETE account-access-consents) do not, and they are how a TPP inspects // or revokes the very consent that cannot be resolved. Failing at authentication would - // lock that door too -- and, since those endpoints are UserXORApplication, would report it + // lock that door too -- and, since those endpoints need only an application, would report it // as ApplicationNotIdentified rather than as anything to do with the consent. ukConsentUnresolved: Option[String] = None, // How the caller's certificate was established (PeerTrust.Resolution.mode): diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 75e7a73b2a..8d81048355 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -1251,43 +1251,31 @@ object Glossary extends MdcLoggable { glossaryItems += GlossaryItem( - title = "API.Endpoint Auth Modes", + title = "API.Entitlements and Scopes", description = s""" | -|Each API endpoint has an **authMode** that determines how Roles are checked when both a User and a Consumer (Application) are present in the request. +|A Role can be held in two ways. A User holds it as an **Entitlement**. A Consumer (an application) holds it as a **Scope**. When an endpoint needs a Role, who is acting on the request decides which one counts. | -|The four auth modes are: +|* **A person is acting.** The Role must be one of the User's Entitlements. The Consumer's Scopes do not count for a person. | -|* **UserOnly** (default): Only the User's Entitlements are checked. Consumer Scopes are ignored. +|* **The Application is acting as itself.** The Role must be one of its Consumer's Scopes. The Application counts as acting as itself only if both of these hold: +| 1. Its Consumer is a Platform App (see ${getGlossaryItemLink("Platform Apps")}). +| 2. It calls with no User, or as its own User. A client-credentials token brings that User: provider *obp-oidc#client* (or *keycloak#client* for a Keycloak service account), named after the client id. | -|* **ApplicationOnly**: Only the Consumer's Scopes are checked. No User is required. +|For example, a person logs in by DirectLogin through a Consumer that holds the Scope *CanGetConsumers*. They still need their own *CanGetConsumers* Entitlement. The same Consumer, if it is a Platform App, can call with a client-credentials token and is let in on its Scope. So whoever is let in is also who is recorded as acting. | -|* **UserXORApplication**: Either a person or the Application acting as itself, never a mix. When the Application acts as itself (there is no User, or the User is the Application's own, the one an OBP-OIDC client-credentials token resolves to: provider *obp-oidc#client*, named after the client id), only the Consumer's Scopes are checked. Otherwise a person is acting, and only the User's Entitlements are checked: the Consumer's Scopes are ignored. It was called *UserOrApplication* until October 2026, when it granted access if either held the Role. +|A Consumer that is not a Platform App can still hold Scopes. They then work only as a limit on people, as described next. | -|* **UserAndApplication**: Access is granted only if the Consumer has the required Scope **AND** the User has the required Entitlement. Both are required. +|**Scopes as a limit on people.** An installation can set *require_scopes_for_all_roles* to true, or list the Role in *require_scopes_for_listed_roles*. A person then needs the Entitlement, and the Consumer they call through needs the Scope. An Application acting as itself still needs only its Scope. | -|For example, if a User logs in via DirectLogin with a Consumer that has the Scope *CanGetConsumers*, and the endpoint's authMode is *UserXORApplication*, the User still needs a personal *CanGetConsumers* Entitlement: a person never acts on the Consumer's Scopes. The same Consumer calling with a client-credentials token, as itself, is let in on its Scope. So whoever is let in is also who is recorded as acting. +|**Who may call an endpoint at all** is set by its list of possible errors: | -|The authMode is set in the ResourceDoc definition for each endpoint, for example: +|* *OBP-20001* (User not logged in), or a Role: a User is required. +|* *OBP-20200* (The application cannot be identified) without the first: an identified Application is required, and a User is optional. Payment providers creating Berlin Group and UK Open Banking consents call like this. +|* Neither: anyone may call. | -|``` -|resourceDocs += ResourceDoc( -| getConsumers, -| implementedInApiVersion, -| nameOf(getConsumers), -| "GET", -| "/management/consumers", -| "Get Consumers", -| ..., -| Some(List(canGetConsumers)), -| authMode = UserXORApplication -|) -|``` -| -|If authMode is not specified, it defaults to UserOnly. -| -|Note: If the property *require_scopes_for_all_roles* is set to true (or *require_scopes_for_listed_roles* lists the Role), endpoints behave as *UserAndApplication* regardless of their configured authMode, except that an Application acting as itself on an *ApplicationOnly* or *UserXORApplication* endpoint still needs only its Scopes: it has no Entitlements to add. +|Endpoints used to carry an *authMode* (UserOnly, ApplicationOnly, UserXORApplication or UserAndApplication). It was removed in November 2026, because who is acting is a fact about each request, not about the endpoint. | |See also: [Access Control](/index#API.Access-Control), [Scopes](/index#group-Scope), [Roles](/index#group-Role) | @@ -3791,7 +3779,7 @@ object Glossary extends MdcLoggable { | |**IMPORTANT - JSON Structure:** | -|The entity name (e.g., "CustomerPreferences") MUST be a direct top-level key in the JSON. Besides the entity name, the root object may only contain the access flags: "hasPersonalEntity", "personalRequiresRole", "hasPublicAccess", "hasCommunityAccess", "useRowLevelAccess" and "authMode" (see ${getGlossaryItemLink("Dynamic-Entity-Access-Model")}). +|The entity name (e.g., "CustomerPreferences") MUST be a direct top-level key in the JSON. Besides the entity name, the root object may only contain the access flags: "hasPersonalEntity", "personalRequiresRole", "hasPublicAccess", "hasCommunityAccess" and "useRowLevelAccess" (see ${getGlossaryItemLink("Dynamic-Entity-Access-Model")}). An "authMode" flag from older clients is accepted and ignored. | |**Common mistake - DO NOT do this:** |```json @@ -4087,7 +4075,7 @@ object Glossary extends MdcLoggable { | |Two settings apply on top of the routes: | -|* `authMode` says which credential satisfies the role checks on the System route: `UserOnly` (Entitlements), `ApplicationOnly` (Consumer Scopes), `UserXORApplication` (Scopes for the Application acting as itself, Entitlements for a person; `UserOrApplication` is accepted as its former name), `UserAndApplication`. The Personal and Row level routes always need a User; `ApplicationOnly` is refused on an entity with `hasPersonalEntity`. +|* Roles: a person needs the entity's Role as an Entitlement. An Application acting as itself needs it as a Scope, and its Consumer must be a Platform App (see ${getGlossaryItemLink("Platform Apps")}). |* Field roles: a field with a `read_role` is omitted from every response unless the caller holds that role; a field with a `write_role` is only changed by PATCH from a holder (POST ignores it, PUT preserves its value). | |**Managing a record's access list (row level)** @@ -7402,6 +7390,39 @@ object Glossary extends MdcLoggable { |""".stripMargin) + glossaryItems += GlossaryItem( + title = "OAuth Standards and OBP", + description = + s""" + |# OAuth Standards and OBP + | + |OAuth decides how an application gets a token. It leaves the access rules inside an API to the API itself. So OBP's Roles, Entitlements and Scopes are OBP's own, and they work alongside these standards: + | + || Standard | What it covers | How OBP uses it | + ||---|---|---| + || [OAuth 2.0, client credentials grant](https://www.rfc-editor.org/rfc/rfc6749#section-4.4) (RFC 6749, section 4.4) | An application gets a token for itself, with no user. | An application acting as itself uses this grant. OBP-API then checks its Consumer's Scopes. | + || [Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591) (RFC 7591) | Standard fields for a client, including `grant_types`, `scope` and `token_endpoint_auth_method`. | A Consumer's grant types use the standard's field and values. See ${getGlossaryItemLink("Consumer Grant Types")}. | + || [JWT access tokens](https://www.rfc-editor.org/rfc/rfc9068) (RFC 9068) | In a token an application got for itself, `sub` is the application's own client id. | OBP-API recognises such a token when `sub` equals the client id (`azp`). | + || [Client authentication with a private key](https://www.rfc-editor.org/rfc/rfc7523) (RFC 7523) and [with a certificate](https://www.rfc-editor.org/rfc/rfc8705) (RFC 8705) | An application proves who it is with a signed key or a client certificate, instead of a shared secret. | A Consumer can hold a JWKS URI and a client certificate for this. | + || [OAuth security best practice](https://www.rfc-editor.org/rfc/rfc9700) (RFC 9700) and OAuth 2.1 | PKCE for every login, and no password grant. | DirectLogin sends a username and password, much like the password grant. Prefer an OAuth2 login for people, and client credentials for applications. | + || [Token exchange](https://www.rfc-editor.org/rfc/rfc8693) (RFC 8693) | Says explicitly that one party acts on behalf of another (the `act` claim). | OBP does not use it today. | + | + |## Permissions for people and permissions for applications + | + |Identity providers usually keep two kinds of permission apart. Microsoft Entra ID, for example, calls them delegated permissions and application permissions: + | + |- A delegated permission says what an application may do for a person who is logged in. + |- An application permission says what an application may do as itself. An administrator grants it. + | + |In OBP, a person's Role comes from their Entitlement. An application acting as itself uses its Consumer's Scopes. With `require_scopes_for_all_roles` or `require_scopes_for_listed_roles`, a Consumer's Scope also limits which Roles people can use through that application. + | + |## OBP Scopes and OAuth scopes + | + |In standard OAuth, the identity provider writes the granted scopes into the token, and the API reads them there. OBP-API instead looks up a Consumer's Scopes in its own records. OAuth allows this, and it lets OBP work with any identity provider. So an OBP Scope is not the same thing as the `scope` in a token, although they share a name. + | + |One setting connects the two. With `oauth2.keycloak.source_of_truth=true`, OBP-API copies the roles in a Keycloak token into the Consumer's Scopes. + |""".stripMargin) + glossaryItems += GlossaryItem( title = "Platform Apps", description = @@ -7438,30 +7459,52 @@ object Glossary extends MdcLoggable { s""" |# Consumer Grant Types | - |A Consumer's grant types say how people and the application itself may call through it. They are optional: a Consumer that sets none allows every way, as before, so people can log in through it and the application can call through it as itself. + |A Consumer's grant types control how tokens can be obtained through the Consumer. They say whether people can log in through it, whether the application can get a token for itself, or both. + | + |A person "logs in through a Consumer" when they get a token with that Consumer's client id or key. They can do this by an OAuth2 login, by DirectLogin, or by renewing such a login. The token then names both the person and the application. + | + |Grant types are optional. A Consumer that has no grant types set can be used in every way, as before. + | + |Grant types only control how tokens are obtained. What the application may do with a token depends on the Scopes its Consumer holds. | - |Setting them lets an installation keep the two uses apart, with one Consumer for people logging in and another for the application acting as itself. One leaked secret then exposes one use, not both; Scopes stay as narrow as each use needs; and metrics tell people's traffic from the application's. + |## Why set them + | + |Some applications both log people in and get tokens for themselves. Such an application can use two Consumers: one for people logging in, and one for the application itself. If one Consumer's secret leaks, only one of the two uses is exposed. Each Consumer holds only the Scopes it needs, and metrics show people's calls and the application's calls separately. | |## Values | - |- `authorization_code`: a person logs in through an OAuth2 / OpenID Connect provider. - |- `refresh_token`: renewing such a login (needs `authorization_code`). - |- `client_credentials`: the application obtains a token as itself. - |- `direct_login`: a person logs in with DirectLogin. + |- `authorization_code`: people log in through an OAuth2 / OpenID Connect provider, such as OBP-OIDC or Keycloak. + |- `refresh_token`: such a login can be renewed. Requires `authorization_code`. + |- `client_credentials`: the application gets a token for itself. + |- `direct_login`: people log in with DirectLogin. + | + |## Examples + | + |Only `client_credentials`: + | + |- The application can get a token for itself through the Consumer. + |- People cannot log in through it, by OAuth2 or by DirectLogin. + | + |`authorization_code`, `refresh_token` and `direct_login`, without `client_credentials`: + | + |- People can log in through the Consumer. + |- The application cannot get a token for itself through it. So it cannot act on its own through this Consumer. + | + |## Where they are checked | - |## What they do + |Grant types are checked twice: | - |- **client_credentials and no login grant**: every call through the Consumer is the application acting as itself, on its Scopes, whatever User its token carries (for example a Keycloak service account). A person cannot get a token through it or call through it. - |- **no client_credentials**: people log in through it and act on their Entitlements; the application never acts as itself through it, and a client-credentials token for it is refused. + |1. When a token is requested. DirectLogin is blocked for a Consumer whose grant types do not include `direct_login`: logging in with its consumer key fails. OBP-OIDC, and Keycloak through OBP's client storage, refuse to issue a token for a grant type that is not included. + |2. When a token is used. OBP-API refuses a call through the Consumer that its grant types do not allow, wherever the token came from. This also catches tokens issued before the grant types were set. | - |The grant types are checked twice. First, when a token is requested: DirectLogin will be blocked for a Consumer whose grant types do not include `direct_login` (logging in with its consumer key fails), and OBP-OIDC and the Keycloak client storage read the grant types from OBP and do not issue a token for a grant that is not included. Second, when a token is used: OBP-API refuses any call through the Consumer that its grant types do not allow, wherever the token was issued. + |## Who sets them | - |Grant types are set by an administrator; they are never inferred from the Scopes a Consumer holds or from it being a Platform App (see ${getGlossaryItemLink("Platform Apps")}), so granting a Scope later does not change how a Consumer behaves. + |An administrator sets a Consumer's grant types. OBP never works them out from other settings, such as the Scopes the Consumer holds or whether it is a Platform App (see ${getGlossaryItemLink("Platform Apps")}). So granting a Consumer a new Scope never changes how it can be used. | |## Endpoints | |- [Get Consumer Grant Types](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getConsumerGrantTypes): `GET /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types` - |- [Update Consumer Grant Types](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateConsumerGrantTypes): `PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types`, with the Role CanUpdateConsumerGrantTypes. An empty list clears them. + |- [Update Consumer Grant Types](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateConsumerGrantTypes): `PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/grant-types`, with the Role CanUpdateConsumerGrantTypes. Sending an empty list removes them, so the Consumer can again be used in every way. |""".stripMargin) glossaryItems += GlossaryItem( @@ -7478,7 +7521,7 @@ object Glossary extends MdcLoggable { | |1. Someone who may grant views of the account (their view lists the target view in `can_grant_access_to_views`, or has `can_grant_access_to_custom_views` for a custom view) gives the Consumer access to a view. The grant records who gave it. |2. The application calls an endpoint of that view with its client-credentials token. Its User is then the Consumer's own (provider `obp-oidc#client`, named after the Consumer's key), and OBP finds the Consumer's grant. - |3. The grant counts only on endpoints that admit an application acting as itself (auth mode ApplicationOnly or UserXORApplication). On every other endpoint the view still needs a User's account access. + |3. The Consumer must be a Platform App. The grant then works like a person's access to the view, on every endpoint that uses the view. | |A person who logs in through the same Consumer does not get the Consumer's account access, and a caller identified by the Consumer Key alone does not either: only the client-credentials token proves the application is calling as itself. | @@ -7706,7 +7749,7 @@ object Glossary extends MdcLoggable { | |The owner, or a holder of CanCreateFileAtOneBank, attaches a file to a record and detaches it. A record is named by `record_type` (e.g. `customer`, or a Dynamic Entity's name) and `record_id`, and a file is attached to a record at most once. OBP does not look the record up, so access to a file never follows from access to a record: it comes only from owning the file, being given access to it, or the Role CanGetFileAtOneBank. | - |These endpoints are UserXORApplication: an application acting as itself, with a client-credentials token, may hold the Roles as Scopes on its Consumer. A person calling through a Consumer still needs their own Entitlements. + |An application acting as itself, with a client-credentials token, may hold the Roles as Scopes on its Consumer, if the Consumer is a Platform App. A person calling through a Consumer still needs their own Entitlements. | |## Public files | 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 42dda1bf38..c5a7d6fb48 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 @@ -348,17 +348,10 @@ object ResourceDocMiddleware extends MdcLoggable { val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc) logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") - // Dispatch on authMode the same way Lift's wrappedWithAuthCheck (APIUtil.scala:1783-1788) does: - // ApplicationOnly | UserXORApplication → applicationAccess (returns ApplicationNotIdentified - // when neither user nor consumer credentials are valid; also accepts consumer-only). - // UserOnly | UserAndApplication → anonymousAccess (returns AuthenticatedUserIsRequired - // when needsAuth is true and user is missing). - // Without this dispatch, every endpoint behaved as UserOnly — breaking - // ApplicationNotIdentified semantics for v5.1.0 createConsumer / getConsumers. - val isAppMode = resourceDoc.authMode match { - case APIUtil.ApplicationOnly | APIUtil.UserXORApplication => true - case _ => false - } + // The error list says who may call (ResourceDoc.requiresApplicationButNotUser). When an identified + // application is enough, applicationAccess accepts a call with no user and refuses one with no application + // (ApplicationNotIdentified). Otherwise anonymousAccess runs, and a required user is checked below. + val isAppMode = resourceDoc.requiresApplicationButNotUser val io = IO.fromFuture(IO( if (isAppMode) APIUtil.applicationAccess(ctx.callContext) else APIUtil.anonymousAccess(ctx.callContext) @@ -372,8 +365,7 @@ object ResourceDocMiddleware extends MdcLoggable { case Right((Full(user), None)) => IO.pure(Right(ctx.copy(user = Full(user)))) // Empty box — no valid credentials provided, and auth is required. - // For UserXORApplication / ApplicationOnly: applicationAccess already returned - // successfully because the consumer is valid (just no user). Pass through. + // When only an application is required, applicationAccess has already succeeded. Pass through. case Right((_, optCC)) if needsAuth && !isAppMode => val cc2 = optCC.getOrElse(ctx.callContext) ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc2).map(Left(_)) @@ -417,11 +409,8 @@ object ResourceDocMiddleware extends MdcLoggable { case Full(user) => val bankId = pathParams.getOrElse("BANK_ID", "") val consumerId = APIUtil.getConsumerPrimaryKey(Some(ctx.callContext)) - // Use handleAccessControlWithAuthMode so authMode = UserXORApplication - // accepts an application acting as itself on its Scopes (the auth-only handleAccess... - // function checks user-entitlements + scopes ANDed and rejects pure - // consumer-scope requests with 403 even under UserXORApplication). - val ok = APIUtil.handleAccessControlWithAuthMode(bankId, user.userId, consumerId, roles, resourceDoc.authMode) + // A person needs an Entitlement, and an application acting as itself needs a Scope. + val ok = APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, user.userId, consumerId, roles) if (ok) success(ctx) else EitherT[IO, Response[IO], ValidationContext]( ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(" or "), ctx.callContext) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index eba457b2ee..105ed0e6c0 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -1568,7 +1568,6 @@ object Http4s400 { ), List(apiTagCustomer, apiTagUser), Some(List(canCreateUserCustomerLinkAtAnyBank, canCreateUserCustomerLink)), - authMode = UserXORApplication, http4sPartialFunction = Some(createUserCustomerLinks)) // ─── getSystemDynamicEntities ───────────────────────────────────────────── diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index f9fcc62167..9af32b00a9 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -792,7 +792,6 @@ object Http4s510 { List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError), List(apiTagConsumer), Some(List(canCreateConsumer)), - authMode = UserXORApplication, http4sPartialFunction = Some(createConsumer) ) @@ -856,7 +855,6 @@ object Http4s510 { ), List(apiTagConsumer), Some(List(canGetConsumers)), - authMode = UserXORApplication, http4sPartialFunction = Some(getConsumers) ) @@ -1234,7 +1232,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UnknownError), apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll)), - authMode = UserXORApplication, http4sPartialFunction = Some(logCacheTraceEndpoint) ) @@ -1261,7 +1258,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UnknownError), apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll)), - authMode = UserXORApplication, http4sPartialFunction = Some(logCacheDebugEndpoint) ) @@ -1288,7 +1284,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UnknownError), apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll)), - authMode = UserXORApplication, http4sPartialFunction = Some(logCacheInfoEndpoint) ) @@ -1315,7 +1310,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UnknownError), apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll)), - authMode = UserXORApplication, http4sPartialFunction = Some(logCacheWarningEndpoint) ) @@ -1342,7 +1336,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UnknownError), apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheError, canGetSystemLogCacheAll)), - authMode = UserXORApplication, http4sPartialFunction = Some(logCacheErrorEndpoint) ) @@ -1369,7 +1362,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UnknownError), apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheAll)), - authMode = UserXORApplication, http4sPartialFunction = Some(logCacheAllEndpoint) ) @@ -2391,7 +2383,6 @@ object Http4s510 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByProviderAndUsername, UnknownError), List(apiTagUser), Some(List(canGetAnyUser)), - authMode = UserXORApplication, http4sPartialFunction = Some(getUserByProviderAndUsername) ) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 0d08a0dbf6..3ef78f0429 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -580,7 +580,7 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CreateDynamicEntityRequestJsonV600] } _ <- validateEntityNameV600(request.entity_name, cc) - _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canCreateDynamicEntityDefinition, cc, code.api.util.APIUtil.UserXORApplication) + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canCreateDynamicEntityDefinition, cc) dynamicEntity <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { DynamicEntityCommons(JSONFactory600.convertV600RequestToInternal(request), None, cc.userId, None) } @@ -2540,7 +2540,7 @@ object Http4s600 { } } - // ─── Phase 2: 3 anonymous/UserXORApplication endpoints ───────────────── + // ─── Phase 2: 3 anonymous or application endpoints ───────────────── // GET /obp/v6.0.0/webui-props/WEBUI_PROP_NAME lazy val getWebUiProp: Http4sRoute = Http4sRoute { @@ -7381,7 +7381,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. `hide_field_from_public_access` (boolean) hides a field of a public entity from callers whose access comes only from its public access, while callers holding the entity's read role still see it. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. - |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserXORApplication` (the Consumer's Scopes when the application acts as itself, the User's Entitlements when a person acts; `UserOrApplication` is accepted as its former name) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. + |* Who may use the entity's data endpoints is set by its Roles: a person needs a Role as an Entitlement. An application acting as itself needs it as a Scope, and its Consumer must be a Platform App. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). |* Set `use_row_level_access` to `true` to decide read, update, delete and grant **per record** with an access list, in place of the entity's Get, Update and Delete roles. The User who creates a record holds all four permissions on it and shares it with `GET/PUT /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access` (a body of `user_id`, `can_read`, `can_update`, `can_delete`, `can_grant`) and `DELETE /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access/USER_ID`; revoking cascades to the grants that user passed on. Records the caller may not read are omitted from list responses and return 404 individually. Creating a record still needs the entity's Create role, `CanGrantDynamicEntityRowAccess_` administers the access list of any record, and field-level read and write roles still apply on top. It cannot be combined with `has_public_access` or `has_community_access`, and is only supported for locally-backed entities. @@ -7410,7 +7410,6 @@ object Http4s600 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicPathAmbiguous, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, Some(canCreateDynamicEntityDefinition :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(createSystemDynamicEntity) ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank resourceDocs += ResourceDoc( @@ -7454,7 +7453,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. `hide_field_from_public_access` (boolean) hides a field of a public entity from callers whose access comes only from its public access, while callers holding the entity's read role still see it. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. - |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserXORApplication` (the Consumer's Scopes when the application acts as itself, the User's Entitlements when a person acts; `UserOrApplication` is accepted as its former name) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. + |* Who may use the entity's data endpoints is set by its Roles: a person needs a Role as an Entitlement. An application acting as itself needs it as a Scope, and its Consumer must be a Platform App. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). |* Set `use_row_level_access` to `true` to decide read, update, delete and grant **per record** with an access list, in place of the entity's Get, Update and Delete roles. The User who creates a record holds all four permissions on it and shares it with `GET/PUT /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access` (a body of `user_id`, `can_read`, `can_update`, `can_delete`, `can_grant`) and `DELETE /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access/USER_ID`; revoking cascades to the grants that user passed on. Records the caller may not read are omitted from list responses and return 404 individually. Creating a record still needs the entity's Create role, `CanGrantDynamicEntityRowAccess_` administers the access list of any record, and field-level read and write roles still apply on top. It cannot be combined with `has_public_access` or `has_community_access`, and is only supported for locally-backed entities. @@ -7490,7 +7489,6 @@ object Http4s600 { ), apiTagManageDynamicEntity :: apiTagApi :: Nil, Some(canCreateDynamicEntityDefinition :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(createBankLevelDynamicEntity) ) resourceDocs += ResourceDoc( @@ -7530,7 +7528,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. `hide_field_from_public_access` (boolean) hides a field of a public entity from callers whose access comes only from its public access, while callers holding the entity's read role still see it. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. - |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserXORApplication` (the Consumer's Scopes when the application acts as itself, the User's Entitlements when a person acts; `UserOrApplication` is accepted as its former name) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. + |* Who may use the entity's data endpoints is set by its Roles: a person needs a Role as an Entitlement. An application acting as itself needs it as a Scope, and its Consumer must be a Platform App. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). |* Set `use_row_level_access` to `true` to decide read, update, delete and grant **per record** with an access list, in place of the entity's Get, Update and Delete roles. The User who creates a record holds all four permissions on it and shares it with `GET/PUT /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access` (a body of `user_id`, `can_read`, `can_update`, `can_delete`, `can_grant`) and `DELETE /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access/USER_ID`; revoking cascades to the grants that user passed on. Records the caller may not read are omitted from list responses and return 404 individually. Creating a record still needs the entity's Create role, `CanGrantDynamicEntityRowAccess_` administers the access list of any record, and field-level read and write roles still apply on top. It cannot be combined with `has_public_access` or `has_community_access`, and is only supported for locally-backed entities. @@ -7594,7 +7592,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. `hide_field_from_public_access` (boolean) hides a field of a public entity from callers whose access comes only from its public access, while callers holding the entity's read role still see it. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. - |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserXORApplication` (the Consumer's Scopes when the application acts as itself, the User's Entitlements when a person acts; `UserOrApplication` is accepted as its former name) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. + |* Who may use the entity's data endpoints is set by its Roles: a person needs a Role as an Entitlement. An application acting as itself needs it as a Scope, and its Consumer must be a Platform App. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). |* Set `use_row_level_access` to `true` to decide read, update, delete and grant **per record** with an access list, in place of the entity's Get, Update and Delete roles. The User who creates a record holds all four permissions on it and shares it with `GET/PUT /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access` (a body of `user_id`, `can_read`, `can_update`, `can_delete`, `can_grant`) and `DELETE /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access/USER_ID`; revoking cascades to the grants that user passed on. Records the caller may not read are omitted from list responses and return 404 individually. Creating a record still needs the entity's Create role, `CanGrantDynamicEntityRowAccess_` administers the access list of any record, and field-level read and write roles still apply on top. It cannot be combined with `has_public_access` or `has_community_access`, and is only supported for locally-backed entities. @@ -7665,7 +7663,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. `hide_field_from_public_access` (boolean) hides a field of a public entity from callers whose access comes only from its public access, while callers holding the entity's read role still see it. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. - |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserXORApplication` (the Consumer's Scopes when the application acts as itself, the User's Entitlements when a person acts; `UserOrApplication` is accepted as its former name) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. + |* Who may use the entity's data endpoints is set by its Roles: a person needs a Role as an Entitlement. An application acting as itself needs it as a Scope, and its Consumer must be a Platform App. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). |* Set `use_row_level_access` to `true` to decide read, update, delete and grant **per record** with an access list, in place of the entity's Get, Update and Delete roles. The User who creates a record holds all four permissions on it and shares it with `GET/PUT /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access` (a body of `user_id`, `can_read`, `can_update`, `can_delete`, `can_grant`) and `DELETE /obp/dynamic-entity/ENTITY_NAME/RECORD_ID/access/USER_ID`; revoking cascades to the grants that user passed on. Records the caller may not read are omitted from list responses and return 404 individually. Creating a record still needs the entity's Create role, `CanGrantDynamicEntityRowAccess_` administers the access list of any record, and field-level read and write roles still apply on top. It cannot be combined with `has_public_access` or `has_community_access`, and is only supported for locally-backed entities. @@ -7988,7 +7986,6 @@ object Http4s600 { ), List(apiTagMetric, apiTagAggregateMetrics), Some(canReadAggregateMetrics :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(getAggregateMetrics) ) resourceDocs += ResourceDoc( @@ -8282,7 +8279,6 @@ object Http4s600 { ), apiTagUser :: Nil, Some(canGetAnyUser :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(getUsers) ) resourceDocs += ResourceDoc( @@ -8386,7 +8382,6 @@ object Http4s600 { ), List(apiTagCustomer, apiTagPerson), Some(canCreateCustomer :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(createCustomer) ) resourceDocs += ResourceDoc( @@ -9622,7 +9617,6 @@ object Http4s600 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagOIDC :: apiTagConsumer :: apiTagOAuth :: Nil, Some(canGetOidcClient :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(getOidcClient) ) resourceDocs += ResourceDoc( @@ -9656,7 +9650,6 @@ object Http4s600 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagOIDC :: apiTagConsumer :: apiTagOAuth :: Nil, Some(canVerifyOidcClient :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(verifyOidcClient) ) resourceDocs += ResourceDoc( @@ -10317,7 +10310,6 @@ object Http4s600 { List(UserHasMissingRoles, InvalidJsonFormat, InvalidLoginCredentials, UsernameHasBeenLocked, UnknownError), apiTagUser :: Nil, Some(canVerifyUserCredentials :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(verifyUserCredentials) ) resourceDocs += ResourceDoc( @@ -13229,7 +13221,6 @@ object Http4s600 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), apiTagUser :: Nil, Some(canGetAnyUser :: Nil), - authMode = code.api.util.APIUtil.UserXORApplication, http4sPartialFunction = Some(getUserByUserId) ) resourceDocs += ResourceDoc( diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 4f9506088b..26983e3091 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -1010,7 +1010,6 @@ case class DynamicEntityDefinitionJsonV600( has_community_access: Boolean = false, personal_requires_role: Boolean = false, use_row_level_access: Boolean = false, - auth_mode: String = "UserOnly", schema: org.json4s.JsonAST.JObject, _links: Option[DynamicEntityLinksJsonV600] = None ) @@ -1030,7 +1029,6 @@ case class DynamicEntityDefinitionWithCountJsonV600( has_community_access: Boolean = false, personal_requires_role: Boolean = false, use_row_level_access: Boolean = false, - auth_mode: String = "UserOnly", schema: org.json4s.JsonAST.JObject, record_count: Long, _links: Option[DynamicEntityLinksJsonV600] = None @@ -1048,7 +1046,6 @@ case class CreateDynamicEntityRequestJsonV600( has_community_access: Option[Boolean] = None, // defaults to false if not provided personal_requires_role: Option[Boolean] = None, // defaults to false if not provided use_row_level_access: Option[Boolean] = None, // defaults to false if not provided - auth_mode: Option[String] = None, // UserOnly | ApplicationOnly | UserXORApplication | UserAndApplication; defaults to UserOnly schema: org.json4s.JsonAST.JObject ) @@ -1060,7 +1057,6 @@ case class UpdateDynamicEntityRequestJsonV600( has_community_access: Option[Boolean] = None, personal_requires_role: Option[Boolean] = None, use_row_level_access: Option[Boolean] = None, - auth_mode: Option[String] = None, schema: org.json4s.JsonAST.JObject ) @@ -2675,6 +2671,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) // Validate that the dynamic key matches entity_name + // authMode: a flag that older definitions may still carry; it is skipped, not shown val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess", "authMode") val dynamicKeyName = fullJson.obj.find(f => !knownFlagFields.contains(f.name)).map(_.name) if (dynamicKeyName.exists(_ != entity.entityName)) { @@ -2699,7 +2696,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { has_community_access = entity.hasCommunityAccess, personal_requires_role = entity.personalRequiresRole, use_row_level_access = entity.useRowLevelAccess, - auth_mode = entity.authMode, schema = schemaObj, _links = Some(links) ) @@ -2723,6 +2719,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) // Validate that the dynamic key matches entity_name + // authMode: a flag that older definitions may still carry; it is skipped, not shown val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess", "authMode") val dynamicKeyName = fullJson.obj.find(f => !knownFlagFields.contains(f.name)).map(_.name) if (dynamicKeyName.exists(_ != entity.entityName)) { @@ -2747,7 +2744,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { has_community_access = entity.hasCommunityAccess, personal_requires_role = entity.personalRequiresRole, use_row_level_access = entity.useRowLevelAccess, - auth_mode = entity.authMode, schema = schema, record_count = recordCount, _links = Some(links) @@ -2780,7 +2776,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val hasCommunityAccess = request.has_community_access.getOrElse(false) val personalRequiresRole = request.personal_requires_role.getOrElse(false) val useRowLevelAccess = request.use_row_level_access.getOrElse(false) - val authMode = code.dynamicEntity.DynamicEntityAuthMode.normalise(request.auth_mode.getOrElse("")) // Build the internal format: entity name as dynamic key + flags JObject( @@ -2790,7 +2785,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { JField("hasCommunityAccess", JBool(hasCommunityAccess)) :: JField("personalRequiresRole", JBool(personalRequiresRole)) :: JField("useRowLevelAccess", JBool(useRowLevelAccess)) :: - JField("authMode", JString(authMode)) :: Nil ) } @@ -2803,7 +2797,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val hasCommunityAccess = request.has_community_access.getOrElse(false) val personalRequiresRole = request.personal_requires_role.getOrElse(false) val useRowLevelAccess = request.use_row_level_access.getOrElse(false) - val authMode = code.dynamicEntity.DynamicEntityAuthMode.normalise(request.auth_mode.getOrElse("")) // Build the internal format: entity name as dynamic key + flags JObject( @@ -2813,7 +2806,6 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { JField("hasCommunityAccess", JBool(hasCommunityAccess)) :: JField("personalRequiresRole", JBool(personalRequiresRole)) :: JField("useRowLevelAccess", JBool(useRowLevelAccess)) :: - JField("authMode", JString(authMode)) :: Nil ) } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index a26fa7c073..f73477d32e 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -832,7 +832,6 @@ object Http4s700 { List(ApplicationNotIdentified, UnknownError), apiTagConsumer :: apiTagApi :: Nil, None, - authMode = UserXORApplication, http4sPartialFunction = Some(getCurrentConsumerIdentity) ) @@ -870,7 +869,6 @@ object Http4s700 { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagConsumer :: Nil, Some(List(ApiRole.canGetConsumers)), - authMode = UserXORApplication, http4sPartialFunction = Some(getConsumers) ) @@ -1101,7 +1099,6 @@ object Http4s700 { List(ApplicationNotIdentified, UnknownError), apiTagConsumer :: apiTagScope :: apiTagApi :: Nil, None, - authMode = UserXORApplication, http4sPartialFunction = Some(getCurrentConsumerScopes) ) @@ -4802,7 +4799,6 @@ object Http4s700 { InsufficientAuthorisationToCreateTransactionRequest, UnknownError), apiTagTransactionRequest :: Nil, None, - authMode = UserXORApplication, http4sPartialFunction = Some(createTransactionRequestOpenCorridor) ) @@ -4851,7 +4847,7 @@ object Http4s700 { |as a Scope at BANK_ID and calls as itself, such as the bank's Bank Node with an OAuth2 client-credentials token. |Such a token gives the application a User of its own (provider obp-oidc#client), so `reported_by_user_id` is |the application's own User when it acted, or the person's when a person did. - |A person calling through a Consumer that holds the Scope still needs their own Entitlement (authMode UserXORApplication). + |A person calling through a Consumer that holds the Scope still needs their own Entitlement. | |Authentication is Required.""".stripMargin, openCorridorPromiseBodyExample, @@ -4872,7 +4868,6 @@ object Http4s700 { OpenCorridorPromiseEvidenceConflict, TransactionRequestLockFailed, UnknownError), apiTagTransactionRequest :: Nil, Some(List(canAttachOpenCorridorPromise)), - authMode = UserXORApplication, http4sPartialFunction = Some(attachOpenCorridorPromise) ) @@ -5388,7 +5383,7 @@ object Http4s700 { | |**Who may call it.** A person with the Role CanSettleOpenCorridor at BANK_ID, or an application whose Consumer holds it |as a Scope at BANK_ID and calls as itself, such as the bank's Bank Node with an OAuth2 client-credentials token. - |A person calling through a Consumer that holds the Scope still needs their own Entitlement (authMode UserXORApplication). + |A person calling through a Consumer that holds the Scope still needs their own Entitlement. | |Authentication is Required.""".stripMargin, JSONFactory700.PostOpenCorridorSettlementJsonV700(other_bank_id = "ke.01.kcs", currency = "KES"), @@ -5409,7 +5404,6 @@ object Http4s700 { $BankNotFound, AmqpBankBrokerNotConfigured, OpenCorridorSettlementAddressMissing, UnknownError), apiTagTransactionRequest :: Nil, Some(List(canSettleOpenCorridor)), - authMode = UserXORApplication, http4sPartialFunction = Some(createOpenCorridorSettlement) ) @@ -5450,7 +5444,7 @@ object Http4s700 { | |**Who may call it.** A person with the Role CanSettleOpenCorridor at BANK_ID, or an application whose Consumer holds it |as a Scope at BANK_ID and calls as itself, such as the bank's Bank Node with an OAuth2 client-credentials token. - |A person calling through a Consumer that holds the Scope still needs their own Entitlement (authMode UserXORApplication). + |A person calling through a Consumer that holds the Scope still needs their own Entitlement. | |Authentication is Required.""".stripMargin, JSONFactory700.PostOpenCorridorFeeSettlementJsonV700(currency = "KES"), @@ -5467,7 +5461,6 @@ object Http4s700 { $BankNotFound, AmqpBankBrokerNotConfigured, OpenCorridorSettlementAddressMissing, UnknownError), apiTagTransactionRequest :: Nil, Some(List(canSettleOpenCorridor)), - authMode = UserXORApplication, http4sPartialFunction = Some(createOpenCorridorFeeSettlement) ) @@ -5506,7 +5499,7 @@ object Http4s700 { | |**Who may call it.** A person with the Role CanSettleOpenCorridor at BANK_ID, or an application whose Consumer holds it |as a Scope at BANK_ID and calls as itself, such as the bank's Bank Node with an OAuth2 client-credentials token. - |A person calling through a Consumer that holds the Scope still needs their own Entitlement (authMode UserXORApplication). + |A person calling through a Consumer that holds the Scope still needs their own Entitlement. | |Authentication is Required.""".stripMargin, EmptyBody, @@ -5536,7 +5529,6 @@ object Http4s700 { OpenCorridorSettlementNotFound, UnknownError), apiTagTransactionRequest :: Nil, Some(List(canSettleOpenCorridor)), - authMode = UserXORApplication, http4sPartialFunction = Some(getOpenCorridorSettlement) ) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala index 41cbd39abb..cac93fbd83 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala @@ -79,9 +79,9 @@ object Http4s700ConsumerAccountAccess { |calling through the same Consumer does not get the Consumer's access, and an application identified |by its Consumer Key alone does not either. | - |The access counts only on the endpoints that admit an application acting as itself (auth mode - |ApplicationOnly or UserXORApplication), such as creating an OPEN_CORRIDOR_PROMISE Transaction Request. - |On every other endpoint the view still needs a User's account access. + |The access then works like a person's access to the view, on every endpoint that uses the view. For + |example, the application can create an OPEN_CORRIDOR_PROMISE Transaction Request. The Consumer must be a + |Platform App. | |Give the Consumer a view with only the permissions it needs, for example a custom view with |can_add_transaction_request_to_any_account, granted through the ManageCustomViews system view. The diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala index d50406502c..883a6e52d6 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerGrantTypes.scala @@ -80,8 +80,11 @@ object Http4s700ConsumerGrantTypes { oauth2_grant_types = List(ConsumerGrantTypes.ClientCredentials)) private val description = - s"""A Consumer's grant types control how the Consumer can be used. They say whether people can log in through - |it, whether the application can call the API as itself, or both. + s"""A Consumer's grant types control how tokens can be obtained through the Consumer. They say whether people can + |log in through it, whether the application can get a token for itself, or both. + | + |A person "logs in through a Consumer" when they get a token with that Consumer's client id or key. They can do this by an OAuth2 + |login, by DirectLogin, or by renewing such a login. The token then names both the person and the application. | |Grant types are optional. A Consumer that has no grant types set can be used in every way, as before. | @@ -92,9 +95,12 @@ object Http4s700ConsumerGrantTypes { |- `client_credentials`: the application gets a token for itself. |- `direct_login`: people log in with DirectLogin. | - |For example, a Consumer with only `client_credentials` is used only by the application. Every call through it - |counts as the application acting as itself, and people cannot log in through it. A Consumer without - |`client_credentials` is used only by people. The application cannot get a token for itself through it. + |For example, through a Consumer with only `client_credentials`, the application can get a token for itself, + |but people cannot log in. Through a Consumer without `client_credentials`, people can log in, but the + |application cannot get a token for itself. + | + |Grant types only control how tokens are obtained. What the application may do with a token depends on the + |Scopes its Consumer holds. | |The response has two lists: | diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700CustomerEmails.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700CustomerEmails.scala index 63697efcd2..22d5b24630 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700CustomerEmails.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700CustomerEmails.scala @@ -29,7 +29,7 @@ package code.api.v7_0_0 import cats.effect.IO import code.api.Constant import code.api.Constant.ApiPathZero -import code.api.util.APIUtil.{Http4sRoute, ResourceDoc, UserXORApplication, unboxFullOrFail} +import code.api.util.APIUtil.{Http4sRoute, ResourceDoc, unboxFullOrFail} import code.api.util.ApiRole.canSendEmailToCustomerAtOneBank import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -182,7 +182,6 @@ object Http4s700CustomerEmails { UnknownError), List(apiTagCustomer), Some(List(canSendEmailToCustomerAtOneBank)), - authMode = UserXORApplication, http4sPartialFunction = Some(sendEmailToCustomer) ).allowSystemSpace() } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala index 2ea8da1125..0177b0ae1d 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala @@ -30,7 +30,7 @@ import cats.effect.IO import code.DynamicData.DynamicData import code.api.dynamic.entity.helper.DynamicEntitySpace import code.api.Constant.ApiPathZero -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserXORApplication, Http4sRoute} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, Http4sRoute} import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -161,7 +161,6 @@ object Http4s700DynamicEntityDefinitions { List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, Some(canGetDynamicEntityDefinitions :: Nil), - authMode = UserXORApplication, http4sPartialFunction = Some(getDynamicEntityDefinitions) ).allowSystemSpace() @@ -197,7 +196,10 @@ object Http4s700DynamicEntityDefinitions { | |The request body is the same as v6.0.0's: `entity_name` in lowercase snake_case, the access flags |(`has_personal_entity`, `has_public_access`, `has_community_access`, `personal_requires_role`, - |`use_row_level_access`, `auth_mode`) and a `schema` whose every property carries an `example`. + |`use_row_level_access`) and a `schema` whose every property carries an `example`. + | + |A person needs the entity's Record Roles as Entitlements. An application acting as itself needs them as + |Scopes, and its Consumer must be a Platform App. | |The caller is granted the new entity's Record Roles (`CanCreateDynamicEntityRecord_` and |its Get, Update and Delete siblings) at the same BANK_ID. @@ -212,7 +214,6 @@ object Http4s700DynamicEntityDefinitions { List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, Some(canCreateDynamicEntityDefinition :: Nil), - authMode = UserXORApplication, http4sPartialFunction = Some(createDynamicEntityDefinition) ).allowSystemSpace() @@ -255,7 +256,6 @@ object Http4s700DynamicEntityDefinitions { DynamicEntityNotFoundByDynamicEntityId, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, Some(canUpdateDynamicEntityDefinition :: Nil), - authMode = UserXORApplication, http4sPartialFunction = Some(updateDynamicEntityDefinition) ).allowSystemSpace() diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Files.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Files.scala index 1d2c840d1e..6244444aea 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Files.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Files.scala @@ -32,7 +32,7 @@ import java.util.Base64 import cats.effect.IO import code.api.Constant.{ApiPathZero, localIdentityProvider} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntitySpace} -import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc, UserXORApplication, unboxFullOrFail} +import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc, unboxFullOrFail} import code.api.util.ApiRole import code.api.util.ApiRole.{canCreateFileAtOneBank, canCreatePublicFileAtOneBank, canGetFileAtOneBank, canUpdateFileScanStatusAtOneBank} import code.api.util.ApiTag._ @@ -118,7 +118,7 @@ object Http4s700Files { Set(user.userId, cc.onBehalfOfUserId).filter(_.nonEmpty) private def holdsRole(bankId: String, user: User, cc: CallContext, role: ApiRole): Boolean = - APIUtil.handleAccessControlWithAuthMode(bankId, user.userId, APIUtil.getConsumerPrimaryKey(Some(cc)), List(role), UserXORApplication) + APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, user.userId, APIUtil.getConsumerPrimaryKey(Some(cc)), List(role)) private def isOwner(file: FileT, user: User, cc: CallContext): Boolean = actingUserIds(user, cc).contains(file.ownerUserId) @@ -266,7 +266,6 @@ object Http4s700Files { FileMediaTypeNotAllowed, FileTooLarge, FileSha256Mismatch, UnknownError), List(apiTagFile), Some(List(canCreateFileAtOneBank)), - authMode = UserXORApplication, http4sPartialFunction = Some(createFile) ).allowSystemSpace() @@ -449,7 +448,6 @@ object Http4s700Files { InvalidFile, FileNotFound, UnknownError), List(apiTagFile), Some(List(canUpdateFileScanStatusAtOneBank)), - authMode = UserXORApplication, http4sPartialFunction = Some(updateFileScanStatus) ).allowSystemSpace() @@ -484,7 +482,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, InvalidFileQueryParameter, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getFilesAttachedToRecord) ).allowSystemSpace() @@ -545,7 +542,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidFileQueryParameter, UnknownError), List(apiTagFile), Some(List(canGetFileAtOneBank, canUpdateFileScanStatusAtOneBank)), - authMode = UserXORApplication, http4sPartialFunction = Some(getFilesOfSpace) ).allowSystemSpace() @@ -573,7 +569,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getMyFiles) ).allowSystemSpace() @@ -602,7 +597,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, FileNotFound, UserHasMissingRoles, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getFile) ).allowSystemSpace() @@ -633,7 +627,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, FileNotFound, UserHasMissingRoles, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getFileContent) ).allowSystemSpace() @@ -669,7 +662,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, FileNotFound, UnknownError), List(apiTagFile), Some(List(canCreatePublicFileAtOneBank)), - authMode = UserXORApplication, http4sPartialFunction = Some(updateFileIsPublic) ).allowSystemSpace() @@ -725,7 +717,6 @@ object Http4s700Files { InvalidFile, FileAttachmentAlreadyExists, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(createFileAttachment) ).allowSystemSpace() @@ -754,7 +745,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, FileNotFound, UserHasMissingRoles, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getFileAttachments) ).allowSystemSpace() @@ -794,7 +784,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, FileNotFound, UserHasMissingRoles, FileAttachmentNotFound, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(deleteFileAttachment) ).allowSystemSpace() @@ -848,7 +837,6 @@ object Http4s700Files { UserNotFoundByUserId, InvalidUserId, FileAccessAlreadyExists, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(createFileAccess) ).allowSystemSpace() @@ -883,7 +871,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, FileNotFound, UserHasMissingRoles, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getFileAccess) ).allowSystemSpace() @@ -918,7 +905,6 @@ object Http4s700Files { List($BankNotFound, $AuthenticatedUserIsRequired, FileNotFound, FileOwnerRequired, FileAccessNotFound, UnknownError), List(apiTagFile), None, - authMode = UserXORApplication, http4sPartialFunction = Some(deleteFileAccess) ).allowSystemSpace() diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Grants.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Grants.scala index 1813a01301..1f90bf270f 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Grants.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Grants.scala @@ -28,7 +28,7 @@ package code.api.v7_0_0 import cats.effect.IO import code.api.Constant.ApiPathZero -import code.api.util.APIUtil.{Http4sRoute, EmptyBody, ResourceDoc, UserXORApplication} +import code.api.util.APIUtil.{Http4sRoute, EmptyBody, ResourceDoc} import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -78,7 +78,7 @@ object Http4s700Grants { s"""Get every Scope on this instance, for all Consumers: its `bank_id`, `role_name` and `consumer_id`. | |A Scope is a Role granted to a Consumer (an application) rather than to a User; see - |${Glossary.getGlossaryItemLink("API.Endpoint Auth Modes")}. For the Scopes of one Consumer, see Get Scopes for Consumer. + |${Glossary.getGlossaryItemLink("API.Entitlements and Scopes")}. For the Scopes of one Consumer, see Get Scopes for Consumer. | |**Who may call it.** A User with the Role CanGetAllScopes, or an application whose Consumer holds it as a Scope. |""".stripMargin, @@ -87,7 +87,6 @@ object Http4s700Grants { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), List(apiTagScope, apiTagRole), Some(List(canGetAllScopes)), - authMode = UserXORApplication, http4sPartialFunction = Some(getAllScopes) ) @@ -125,7 +124,6 @@ object Http4s700Grants { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), List(apiTagRole, apiTagEntitlement), Some(List(canGetReachableRoles)), - authMode = UserXORApplication, http4sPartialFunction = Some(getReachableRoles) ) } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala index ce382929c3..6f982c0486 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala @@ -53,12 +53,12 @@ case class MyRoleJsonV700( caller: MyRoleCallerJsonV700, held_by: MyRoleHeldByJsonV700, scopes_required: Boolean, - allowed_for_auth_modes: List[String] + allowed: Boolean ) /** * One Role, as the caller holds it: whether the calling User holds it, whether the calling Consumer holds it - * as a Scope, and so for which endpoint auth modes an endpoint that needs this Role would let the caller in. + * as a Scope, and so whether an endpoint that needs this Role would let the caller in. * * A service in front of OBP-API (API Explorer II, the Portal, API Manager) forwards the token it was given * and gets OBP-API's own answer, instead of copying OBP-API's access rules. @@ -74,10 +74,8 @@ object Http4s700MyRoles { val resourceDocs = ArrayBuffer[ResourceDoc]() - private val authModes: List[EndpointAuthMode] = List(UserOnly, ApplicationOnly, UserXORApplication, UserAndApplication) - - /** The same decision as handleAccessControlWithAuthMode, for each auth mode, without granting - * just-in-time Entitlements: a GET must not create them. */ + /** The same decision as handleAccessControlRegardingEntitlementsAndScopes, without granting just-in-time + * Entitlements: a GET must not create them. */ private def myRole(roleName: String, bankId: String, cc: CallContext): Future[MyRoleJsonV700] = for { consumer <- Future(cc.consumer match { @@ -92,8 +90,9 @@ object Http4s700MyRoles { val heldAt = if (role.requiresBankId) bankId else "" val user = cc.user.toOption val userId = user.map(_.userId).getOrElse("") - // A client-credentials token still brings a pseudo-user, named after the Consumer's client id. - val applicationOnly = user.forall(u => isApplicationsOwnUser(u, consumer)) + // The application acts as itself only if its Consumer is a Platform App, calling as its own user (a + // client-credentials token brings one, named after the Consumer's client id) or with no user. + val applicationOnly = isPlatformApp(consumer) && user.forall(u => isApplicationsOwnUser(u, consumer)) val virtualRoles = if (userId.isEmpty) Nil else if (isSuperAdmin(userId)) superAdminVirtualRoles else if (isOidcOperator(userId)) oidcOperatorVirtualRoles @@ -103,19 +102,10 @@ object Http4s700MyRoles { val heldByConsumer = hasScope(heldAt, consumer.id.get.toString, role) val listed = getPropsValue("require_scopes_for_listed_roles", "").split(",").contains(role.toString) val scopesRequired = ApiPropsWithAlias.requireScopesForAllRoles || listed - val allowed = authModes.filter { mode => - virtual || (if (scopesRequired) mode match { - case ApplicationOnly | UserXORApplication if applicationOnly => heldByConsumer - case _ => heldByUser && heldByConsumer - } else mode match { - case UserOnly => heldByUser - case ApplicationOnly => heldByConsumer - case UserXORApplication => if (applicationOnly) heldByConsumer else heldByUser - case UserAndApplication => heldByUser && heldByConsumer - }) - } + val allowed = virtual || + (if (applicationOnly) heldByConsumer else if (scopesRequired) heldByUser && heldByConsumer else heldByUser) MyRoleJsonV700(role.toString, bankId, MyRoleCallerJsonV700(userId, consumer.consumerId.get, applicationOnly), - MyRoleHeldByJsonV700(heldByUser, heldByConsumer), scopesRequired, allowed.map(_.toString)) + MyRoleHeldByJsonV700(heldByUser, heldByConsumer), scopesRequired, allowed) } // Route: GET /obp/v7.0.0/banks/BANK_ID/my/roles/ROLE_NAME (BANK_ID SYS for system Roles) @@ -130,7 +120,7 @@ object Http4s700MyRoles { caller = MyRoleCallerJsonV700(user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", application_only = true), held_by = MyRoleHeldByJsonV700(user = false, consumer = true), scopes_required = false, - allowed_for_auth_modes = List("ApplicationOnly", "UserXORApplication") + allowed = true ) resourceDocs += ResourceDoc( @@ -147,16 +137,17 @@ object Http4s700MyRoles { |Use it from a service that receives an OBP-API token (for example API Explorer II, the Portal or API Manager) |and must decide, as OBP-API would, whether that token may see something the service guards with an OBP Role. | - |* `caller`: the User and Consumer of the token. `application_only` is true when no person is behind it - | (a client-credentials token, whose User is a pseudo-user named after the Consumer's client id). + |* `caller`: the User and Consumer of the token. `application_only` is true when the application is acting as + | itself. That means its Consumer is a Platform App, and it calls with no User or as its own User. A + | client-credentials token brings that User, named after the Consumer's client id. |* `held_by.user`: the User holds the Role as an Entitlement, or as a virtual Role from `super_admin_user_ids` or | `oidc_operator_user_ids`. |* `held_by.consumer`: the Consumer holds the Role as a Scope. - |* `scopes_required`: the props `require_scopes_for_all_roles` or `require_scopes_for_listed_roles` make - | every endpoint need both. - |* `allowed_for_auth_modes`: the endpoint auth modes (`UserOnly`, `ApplicationOnly`, `UserXORApplication`, - | `UserAndApplication`) under which an endpoint that needs this Role would let the caller in. - | An endpoint that accepts several Roles lets the caller in if any one of them is allowed. + |* `scopes_required`: the props `require_scopes_for_all_roles` or `require_scopes_for_listed_roles` say that + | a person also needs the Role as a Scope on the Consumer they call through. + |* `allowed`: an endpoint that needs this Role would let the caller in. An application acting as itself needs + | the Scope. A person needs the Entitlement, and also the Scope when `scopes_required` is true. A virtual Role + | is always allowed. An endpoint that accepts several Roles lets the caller in if any one of them is allowed. | |Just-in-time Entitlements (`create_just_in_time_entitlements`) are not granted here: an endpoint may still |grant one when it is called. @@ -169,7 +160,6 @@ object Http4s700MyRoles { List(ApplicationNotIdentified, BankNotFound, IncorrectRoleName, EntitlementIsSystemRole, UnknownError), List(apiTagRole, apiTagEntitlement, apiTagScope), None, - authMode = UserXORApplication, http4sPartialFunction = Some(getMyRole) ).allowSystemSpace() } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala index 32018de7f8..bc5652d3d0 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala @@ -28,7 +28,7 @@ package code.api.v7_0_0 import cats.effect.IO import code.api.Constant.ApiPathZero -import code.api.util.APIUtil.{Http4sRoute, EmptyBody, ResourceDoc, UserXORApplication, unboxFullOrFail, userAuthenticationMessage} +import code.api.util.APIUtil.{Http4sRoute, EmptyBody, ResourceDoc, unboxFullOrFail, userAuthenticationMessage} import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -118,7 +118,6 @@ object Http4s700OpenIdConnect { List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConsentNotFound, UserNotFoundByUserId, UnknownError), apiTagOIDC :: apiTagConsent :: apiTagOAuth :: Nil, Some(canGetOidcConsent :: Nil), - authMode = UserXORApplication, http4sPartialFunction = Some(getOidcConsent) ) } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala index 0e080f5710..18ea8c2803 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala @@ -28,7 +28,7 @@ package code.api.v7_0_0 import cats.effect.IO import code.api.Constant.ApiPathZero -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserXORApplication, Http4sRoute} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, Http4sRoute} import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -265,7 +265,6 @@ object Http4s700PlatformApps { List(ApplicationNotIdentified, InvalidJsonFormat, PlatformAppNotFound, InvalidPlatformAppDeclaration, UnknownError), List(apiTagConsumer, apiTagScope), None, - authMode = UserXORApplication, http4sPartialFunction = Some(updateCurrentConsumerPlatformApp) ) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Telemetry.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Telemetry.scala index dc9f42e52b..95e22dd0a3 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Telemetry.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Telemetry.scala @@ -28,7 +28,7 @@ package code.api.v7_0_0 import cats.effect.IO import code.api.Constant.ApiPathZero -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserXORApplication, Http4sRoute} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, Http4sRoute} import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -119,7 +119,6 @@ object Http4s700Telemetry { List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), List(apiTagApi, apiTagSystem), Some(List(canGetTelemetry)), - authMode = UserXORApplication, http4sPartialFunction = Some(getTelemetry) ) } diff --git a/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala b/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala index 7f042f40af..730427d18f 100644 --- a/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala +++ b/obp-api/src/main/scala/code/consumer/ConsumerGrantTypes.scala @@ -33,20 +33,23 @@ import com.openbankproject.commons.model.User import net.liftweb.common.Box /** - * A Consumer's grant types: how the Consumer can be used. + * A Consumer's grant types: how tokens can be obtained through the Consumer. * - * They say whether people can log in through the Consumer, whether the application can call the API as itself, + * They say whether people can log in through the Consumer, whether the application can get a token for itself, * or both. They are optional, and only an administrator can set them. * + * A person "logs in through a Consumer" when they get a token with that Consumer's client id or key. They can do this by an OAuth2 + * login, by DirectLogin, or by renewing such a login. The token then names both the person and the application. + * * A Consumer with no grant types set (every existing Consumer) can be used in every way, exactly as before. * - * A Consumer with grant types set is used as they say. For example: + * Grant types only control how tokens are obtained. They do not decide what the application may do with a token, + * or whether a call counts as the application acting as itself. For example: * - * - Only client_credentials: only the application uses the Consumer. Every call through it counts as the - * application acting as itself, even if the token carries a user account (such as a Keycloak service - * account). People cannot log in through it. - * - No client_credentials: only people use the Consumer. The application cannot get a token for itself through - * it, and the Consumer's Scopes never count for a call the application makes on its own. + * - Only client_credentials: the application can get a token for itself through the Consumer. People cannot + * log in through it. + * - No client_credentials: people can log in through the Consumer. The application cannot get a token for itself + * through it, so it cannot act on its own through this Consumer. * * OBP never works out the grant types from other settings, such as the Scopes the Consumer holds or whether it * is a Platform App. So granting a Consumer a new Scope never changes how it can be used. @@ -87,13 +90,6 @@ object ConsumerGrantTypes { /** True when the Consumer sets no grant types, or lists this one. */ def allows(consumer: Consumer, grantType: String): Boolean = !isSet(consumer) || of(consumer).contains(grantType) - /** Only the application acting as itself: client_credentials and no login grant. */ - def isClientCredentialsOnly(consumer: Consumer): Boolean = - isSet(consumer) && of(consumer).contains(ClientCredentials) && !of(consumer).exists(PersonLogins.contains) - - /** Grant types set, client_credentials not among them: the application never acts as itself through it. */ - def excludesClientCredentials(consumer: Consumer): Boolean = isSet(consumer) && !of(consumer).contains(ClientCredentials) - /** The OAuth2 grant types an identity provider may issue tokens for (DirectLogin is OBP's own, not OAuth2). */ def oauth2GrantTypes(consumer: Consumer): List[String] = if (isSet(consumer)) of(consumer).filterNot(_ == DirectLogin) else DefaultOAuth2 diff --git a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala index 8c64217177..0074aa0bd0 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala @@ -70,13 +70,6 @@ trait DynamicEntityT { def personalRequiresRole: Boolean def useRowLevelAccess: Boolean - /** - * Who may hold the roles guarding this entity's data endpoints (see [[DynamicEntityAuthMode]]): - * the User's Entitlements, the Consumer's Scopes, either, or both. Personal ("my") endpoints - * always require a User regardless of this value. - */ - def authMode: String - /** * Add Option(bank_id) to Dynamic Entity. * Then we should treat the two cases very separately. @@ -480,28 +473,6 @@ object ReferenceType extends MdcLoggable { } } -/** - * Auth mode of a Dynamic Entity's data endpoints, stored as its name. Mirrors - * [[code.api.util.APIUtil.EndpointAuthMode]] without dragging APIUtil into the model. - */ -object DynamicEntityAuthMode { - val UserOnly = "UserOnly" - val ApplicationOnly = "ApplicationOnly" - val UserXORApplication = "UserXORApplication" - val UserAndApplication = "UserAndApplication" - /** The former name of UserXORApplication, still accepted on input and in stored rows. */ - val LegacyUserOrApplication = "UserOrApplication" - val all: List[String] = List(UserOnly, ApplicationOnly, UserXORApplication, UserAndApplication) - val default: String = UserOnly - def isValid(value: String): Boolean = all.contains(value) - /** null / empty (rows created before the column existed) read as the default; the legacy name reads as UserXORApplication. */ - def normalise(value: String): String = Option(value).map(_.trim).filter(_.nonEmpty) match { - case Some(LegacyUserOrApplication) => UserXORApplication - case Some(v) => v - case None => default - } -} - case class DynamicEntityCommons(entityName: String, metadataJson: String, dynamicEntityId: Option[String] = None, @@ -511,8 +482,7 @@ case class DynamicEntityCommons(entityName: String, hasPublicAccess: Boolean = false, hasCommunityAccess: Boolean = false, personalRequiresRole: Boolean = false, - useRowLevelAccess: Boolean = false, - authMode: String = DynamicEntityAuthMode.default + useRowLevelAccess: Boolean = false ) extends DynamicEntityT with JsonFieldReName object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommons] { @@ -556,7 +526,8 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo val fields = jsonObject.obj - // Known flag field names at the root level (not the entity definition itself) + // Known flag field names at the root level (not the entity definition itself). authMode is accepted so that + // clients written before endpoint auth modes were removed are not refused, but it is ignored. val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess", "authMode") // validate root object fields @@ -574,12 +545,6 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo val personalRequiresRoleValue: Boolean = fields.filter(_.name == "personalRequiresRole").map(_.value.asInstanceOf[JBool].values).headOption.getOrElse(false) // Determine the value of useRowLevelAccess; use the field's boolean value if provided, otherwise default to false val useRowLevelAccessValue: Boolean = fields.filter(_.name == "useRowLevelAccess").map(_.value.asInstanceOf[JBool].values).headOption.getOrElse(false) - // Determine the value of authMode; a string naming an EndpointAuthMode, default UserOnly - val authModeValue: String = fields.filter(_.name == "authMode").map(_.value).headOption match { - case Some(JString(v)) => DynamicEntityAuthMode.normalise(v) - case Some(JNull) | Some(JNothing) | None => DynamicEntityAuthMode.default - case Some(_) => "" // wrong JSON type: fails the validity check below - } checkFormat(fields.nonEmpty, s"$DynamicEntityInstanceValidateFail The Json root object should have a single entity, but current have none.") checkFormat(entityFields.size == 1, s"$DynamicEntityInstanceValidateFail The Json root object should have exactly one entity field (plus optional flags: ${knownFlagFields.mkString(", ")}), but current root objects: ${fields.map(_.name).mkString(", ")}") @@ -587,15 +552,6 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo flagFields.forall(f => knownFlagFields.contains(f.name)), s"$DynamicEntityInstanceValidateFail Unknown flag fields. Allowed flags: ${knownFlagFields.mkString(", ")}. Current root objects: ${fields.map(_.name).mkString(", ")}" ) - checkFormat( - DynamicEntityAuthMode.isValid(authModeValue), - s"$DynamicEntityInstanceValidateFail authMode must be one of ${DynamicEntityAuthMode.all.mkString(", ")}." - ) - // An application-only call has no User, so an entity whose rows are personal ("my") cannot be ApplicationOnly. - checkFormat( - !(hasPersonalEntityValue && authModeValue == DynamicEntityAuthMode.ApplicationOnly), - s"$DynamicEntityInstanceValidateFail authMode ${DynamicEntityAuthMode.ApplicationOnly} cannot be combined with hasPersonalEntity." - ) // §8.3: useRowLevelAccess is a master switch — mutually exclusive with public/community access. checkFormat( !(useRowLevelAccessValue && (hasPublicAccessValue || hasCommunityAccessValue)), @@ -756,7 +712,7 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo } }) - DynamicEntityCommons(entityName, compactRender(jsonObject), dynamicEntityId, userId, bankId, hasPersonalEntityValue, hasPublicAccessValue, hasCommunityAccessValue, personalRequiresRoleValue, useRowLevelAccessValue, authModeValue) + DynamicEntityCommons(entityName, compactRender(jsonObject), dynamicEntityId, userId, bankId, hasPersonalEntityValue, hasPublicAccessValue, hasCommunityAccessValue, personalRequiresRoleValue, useRowLevelAccessValue) } // `reference` is an internal query-layer type (see DynamicEntityFieldType.reference), never declared diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala index 8cbf8a001d..7e669b8f95 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala @@ -119,7 +119,6 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson .HasCommunityAccess(dynamicEntity.hasCommunityAccess) .PersonalRequiresRole(dynamicEntity.personalRequiresRole) .UseRowLevelAccess(dynamicEntity.useRowLevelAccess) - .AuthMode(DynamicEntityAuthMode.normalise(dynamicEntity.authMode)) .saveMe() code.api.dynamic.entity.helper.DynamicEntityHelper.forgetDefinitions() fieldTypesBefore.foreach(code.dynamicentitywebhook.DynamicEntityWebhookDefinitionChanges.afterUpdate(saved, _)) @@ -131,7 +130,7 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson try { val info = code.api.dynamic.entity.helper.DynamicEntityInfo( dynamicEntity.metadataJson, dynamicEntity.entityName, dynamicEntity.bankId, - dynamicEntity.hasPersonalEntity, dynamicEntity.hasPublicAccess, dynamicEntity.hasCommunityAccess, dynamicEntity.personalRequiresRole, dynamicEntity.useRowLevelAccess, dynamicEntity.authMode) + dynamicEntity.hasPersonalEntity, dynamicEntity.hasPublicAccess, dynamicEntity.hasCommunityAccess, dynamicEntity.personalRequiresRole, dynamicEntity.useRowLevelAccess) val scalar = code.api.dynamic.entity.projection.ProjectionProvisioner.scalarFieldsOf(info.indexedFields) if (scalar.nonEmpty) code.api.dynamic.entity.projection.ProjectionProvisioner @@ -191,8 +190,8 @@ class DynamicEntity extends DynamicEntityT with LongKeyedMapper[DynamicEntity] w object HasCommunityAccess extends MappedBoolean(this) object PersonalRequiresRole extends MappedBoolean(this) object UseRowLevelAccess extends MappedBoolean(this) - // Name of an EndpointAuthMode; null for rows created before the column existed (read as UserOnly). - object AuthMode extends MappedString(this, 32) + // There was an AuthMode column here (endpoint auth modes, removed in November 2026). Existing databases keep the + // column, unused. override def dynamicEntityId: Option[String] = Option(DynamicEntityId.get) override def entityName: String = EntityName.get @@ -208,7 +207,6 @@ class DynamicEntity extends DynamicEntityT with LongKeyedMapper[DynamicEntity] w override def hasCommunityAccess: Boolean = HasCommunityAccess.get override def personalRequiresRole: Boolean = PersonalRequiresRole.get override def useRowLevelAccess: Boolean = UseRowLevelAccess.get - override def authMode: String = DynamicEntityAuthMode.normalise(AuthMode.get) } object DynamicEntity extends DynamicEntity with LongKeyedMetaMapper[DynamicEntity] { diff --git a/obp-api/src/main/scala/code/obp/grpc/logcache/LogCacheStreamServiceImpl.scala b/obp-api/src/main/scala/code/obp/grpc/logcache/LogCacheStreamServiceImpl.scala index ad337dc20e..ccc157be4c 100644 --- a/obp-api/src/main/scala/code/obp/grpc/logcache/LogCacheStreamServiceImpl.scala +++ b/obp-api/src/main/scala/code/obp/grpc/logcache/LogCacheStreamServiceImpl.scala @@ -30,7 +30,6 @@ package code.obp.grpc.logcache import org.json4s._ import code.api.cache.RedisLogger import code.api.util.APIUtil -import code.api.util.APIUtil.UserOnly import code.logcache.LogCacheEventBus import code.obp.grpc.chat.AuthInterceptor import code.obp.grpc.logcache.api._ @@ -73,7 +72,7 @@ object LogCacheStreamServiceImpl extends LogCacheStreamServiceGrpc.LogCacheStrea val requiredRoles = RedisLogger.LogLevel.requiredRoles(internalLevel) val callContext = Option(AuthInterceptor.CALL_CONTEXT_KEY.get()) val consumerId = APIUtil.getConsumerPrimaryKey(callContext) - if (!APIUtil.handleAccessControlWithAuthMode("", user.userId, consumerId, requiredRoles, UserOnly)) { + if (!APIUtil.handleAccessControlRegardingEntitlementsAndScopes("", user.userId, consumerId, requiredRoles)) { responseObserver.onError(Status.PERMISSION_DENIED .withDescription(s"Missing entitlement for log level $internalLevel").asRuntimeException()) return diff --git a/obp-api/src/main/scala/code/obp/grpc/metricsstream/MetricsStreamServiceImpl.scala b/obp-api/src/main/scala/code/obp/grpc/metricsstream/MetricsStreamServiceImpl.scala index 4386674b8a..e54a46668f 100644 --- a/obp-api/src/main/scala/code/obp/grpc/metricsstream/MetricsStreamServiceImpl.scala +++ b/obp-api/src/main/scala/code/obp/grpc/metricsstream/MetricsStreamServiceImpl.scala @@ -29,7 +29,6 @@ package code.obp.grpc.metricsstream import org.json4s._ import code.api.util.APIUtil -import code.api.util.APIUtil.UserOnly import code.api.util.ApiRole.canReadMetrics import code.metricsstream.MetricsEventBus import code.obp.grpc.chat.AuthInterceptor @@ -67,7 +66,7 @@ object MetricsStreamServiceImpl extends MetricsStreamServiceGrpc.MetricsStreamSe val callContext = Option(AuthInterceptor.CALL_CONTEXT_KEY.get()) val consumerId = APIUtil.getConsumerPrimaryKey(callContext) - if (!APIUtil.handleAccessControlWithAuthMode("", user.userId, consumerId, List(canReadMetrics), UserOnly)) { + if (!APIUtil.handleAccessControlRegardingEntitlementsAndScopes("", user.userId, consumerId, List(canReadMetrics))) { responseObserver.onError(Status.PERMISSION_DENIED .withDescription("Missing entitlement canReadMetrics").asRuntimeException()) return diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala index b75a92361b..59272422e4 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala @@ -27,7 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.api.UKOpenBanking.v3_1_0 -import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, UserXORApplication, buildOperationId} +import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, buildOperationId} import code.api.util.ErrorMessages.ConsentNotFound import code.consent.Consents import com.openbankproject.commons.model.ErrorMessage @@ -80,13 +80,13 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { postUnauthed("{}", "account-access-consents").code should equal(401) } // See the twin scenario in UKOpenBankingV401AccountInfoTests for why this is pinned: lodging is - // a client-credentials call with no PSU, and the ResourceDoc default (UserOnly) would 401 it as - // soon as a client-credentials token stops auto-vivifying a user. - scenario("ResourceDoc declares UserXORApplication so a PSU-less TPP call is not rejected", UKOpenBankingV310) { + // a client-credentials call with no PSU, and a doc listing AuthenticatedUserIsRequired would 401 + // it as soon as a client-credentials token stops auto-vivifying a user. + scenario("ResourceDoc needs only an identified application, so a PSU-less TPP call is not rejected", UKOpenBankingV310) { val docs = ResourceDoc.getResourceDocs( List(buildOperationId(ApiVersion.ukOpenBankingV31, "createAccountAccessConsents"))) docs should not be empty - docs.foreach(_.authMode should equal(UserXORApplication)) + docs.foreach(_.requiresApplicationButNotUser shouldBe true) } } feature("UKOB v3.1 DELETE /account-access-consents/CONSENT_ID") { diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 129ee644f3..87f4794976 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -28,7 +28,7 @@ TESOBE (http://www.tesobe.com/) package code.api.UKOpenBanking.v4_0_1 import code.api.Constant -import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, UserXORApplication, buildOperationId} +import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, buildOperationId} import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentExpiredIssue, ConsentIdClaimMissing, ConsentNotFound} import code.api.util.{CallContext, CertificateUtil, Consent} import code.consent.{ConsentStatus, Consents} @@ -188,16 +188,16 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { postUnauthed(consentPostBody, "aisp", "account-access-consents").code should equal(401) } // Lodging a consent is a client-credentials call: the TPP is authenticated as an app and no PSU - // exists yet. UserOnly (the ResourceDoc default) sends ResourceDocMiddleware down anonymousAccess, - // which 401s a request carrying no user. Pinned here because nothing else would notice the doc - // silently reverting to the default -- the endpoint would keep working for as long as OAuth2 + // exists yet. A doc listing AuthenticatedUserIsRequired sends ResourceDocMiddleware down + // anonymousAccess, which 401s a request carrying no user. Pinned here because nothing else would + // notice the doc's error list changing back -- the endpoint would keep working for as long as OAuth2 // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day // that stops. - scenario("ResourceDoc declares UserXORApplication so a PSU-less TPP call is not rejected", UKOpenBankingV401AccountInfo) { + scenario("ResourceDoc needs only an identified application, so a PSU-less TPP call is not rejected", UKOpenBankingV401AccountInfo) { val docs = ResourceDoc.getResourceDocs( List(buildOperationId(ApiVersion.ukOpenBankingV401, "createAccountAccessConsents"))) docs should not be empty - docs.foreach(_.authMode should equal(UserXORApplication)) + docs.foreach(_.requiresApplicationButNotUser shouldBe true) } scenario("all three datetime fields omitted -> 201, open-ended (no expiry/date restriction)", UKOpenBankingV401AccountInfo) { val bodyWithoutDates = diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala index 1dc732d13a..e22dd8b2a4 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala @@ -27,7 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.api.UKOpenBanking.v4_0_1 -import code.api.util.APIUtil.{ResourceDoc, UserXORApplication, buildOperationId} +import code.api.util.APIUtil.{ResourceDoc, buildOperationId} import code.api.util.{CallContext, Consent} import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} import code.model.UserX @@ -289,20 +289,20 @@ class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { } feature("consent-by-id ResourceDocs accept a client-credentials caller") { - // Without this the docs default to UserOnly, which sends ResourceDocMiddleware down + // Without ApplicationNotIdentified in place of AuthenticatedUserIsRequired, the middleware goes down // anonymousAccess and 401s any request carrying no user -- so the rule above would never be // reached. Pinned because nothing else would notice a revert: these endpoints keep working for // as long as OAuth2 token parsing auto-vivifies a user for a client-credentials token. for (name <- List("getAccountAccessConsentsConsentId", "deleteAccountAccessConsentsConsentId")) { - scenario(s"v4.0.1 $name declares UserXORApplication", UKOpenBankingV401ConsentAccess) { + scenario(s"v4.0.1 $name needs only an identified application", UKOpenBankingV401ConsentAccess) { val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.ukOpenBankingV401, name))) docs should not be empty - docs.foreach(_.authMode should equal(UserXORApplication)) + docs.foreach(_.requiresApplicationButNotUser shouldBe true) } - scenario(s"v3.1 $name declares UserXORApplication", UKOpenBankingV401ConsentAccess) { + scenario(s"v3.1 $name needs only an identified application", UKOpenBankingV401ConsentAccess) { val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.ukOpenBankingV31, name))) docs should not be empty - docs.foreach(_.authMode should equal(UserXORApplication)) + docs.foreach(_.requiresApplicationButNotUser shouldBe true) } } } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index 0d9f31e682..dad2596fbe 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -979,20 +979,20 @@ class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures { // Reading a consent's authorisation sub-resources is the TPP polling its own consent -- in Berlin // Group the PSU never calls the API at all, it authenticates at the ASPSP under Redirect or hands // its factors to the TPP under Embedded. Both handlers already reflect that, taking no user. The - // ResourceDocs did not, and a doc left on the UserOnly default sends the middleware down + // ResourceDocs did not, and a doc listing AuthenticatedUserIsRequired sends the middleware down // anonymousAccess, which 401s a request carrying no user. // // Pinned because nothing else would notice a revert: these keep working for as long as OAuth2 // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day - // that stops. The consent endpoints in the same file have been UserXORApplication all along, so + // that stops. The consent endpoints in the same file need only an application too, so // this is also a consistency guard within the family. feature("BG v1.3 - consent authorisation sub-resources accept a client-credentials caller") { for (name <- List(nameOf(Http4sBGv13AIS.getConsentAuthorisation), nameOf(Http4sBGv13AIS.getConsentScaStatus))) { - scenario(s"$name declares UserXORApplication", BerlinGroupV1_3) { + scenario(s"$name needs only an identified application", BerlinGroupV1_3) { val docs = APIUtil.ResourceDoc.getResourceDocs( List(APIUtil.buildOperationId(ConstantsBG.berlinGroupVersion1, name))) docs should not be empty - docs.foreach(_.authMode should equal(APIUtil.UserXORApplication)) + docs.foreach(_.requiresApplicationButNotUser shouldBe true) } } } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala index aba8763e66..dcc4088926 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala @@ -727,8 +727,8 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { } } - // The GET siblings were brought to UserXORApplication on their own; these two were held back - // because a doc's auth mode says nothing useful until the handler has an answer to which PSU an + // The GET siblings were opened to an application on its own first; these two were held back + // because the doc says nothing useful until the handler has an answer to which PSU an // authorisation is for. They now do, and the answer does not come from the session, so the docs // can state what these calls have always been: the TPP acting as itself. // @@ -746,11 +746,11 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { "updateConsentsPsuDataUpdateAuthorisationConfirmation" ) for (name <- authorisationDocs) { - scenario(s"$name declares UserXORApplication", BerlinGroupV13ConsentAccess) { + scenario(s"$name needs only an identified application", BerlinGroupV13ConsentAccess) { val docs = APIUtil.ResourceDoc.getResourceDocs( List(APIUtil.buildOperationId(ConstantsBG.berlinGroupVersion1, name))) docs should not be empty - docs.foreach(_.authMode should equal(APIUtil.UserXORApplication)) + docs.foreach(_.requiresApplicationButNotUser shouldBe true) } } } diff --git a/obp-api/src/test/scala/code/api/sweep/AuthSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/AuthSweepTest.scala index c28b8bac4a..4de2b0bb36 100644 --- a/obp-api/src/test/scala/code/api/sweep/AuthSweepTest.scala +++ b/obp-api/src/test/scala/code/api/sweep/AuthSweepTest.scala @@ -151,7 +151,7 @@ class AuthSweepTest extends ServerSetupWithTestData with DefaultUsers with Sweep "OBPv4.0.0-verifyRequestSignResponse" -> ("Refuses with OBP-20311 'The Request is not signed' -- JWS request signing, a third " + "authentication mechanism alongside user and application. ResourceDoc has no way to " + - "declare it: authMode covers user/application only, so neither the doc nor this sweep " + + "declare it: its error list covers user and application only, so neither the doc nor this sweep " + "can express the requirement. The 401 is correct; only the message differs.") ) @@ -198,8 +198,8 @@ class AuthSweepTest extends ServerSetupWithTestData with DefaultUsers with Sweep * * So a 401 is only a violation when it is the USER one. An application-auth 401 is reported * as an observation instead of a failure -- named, not silently swallowed, because the doc - * still has no machine-readable way to say "needs an application" unless someone sets - * authMode, and a reader of resource-docs cannot tell. + * does not say "needs an application" unless its error list names ApplicationNotIdentified, + * and a reader of resource-docs cannot tell. */ private def checkPublicIsNot401(doc: ResourceDoc): Option[String] = { val (code, json) = call(doc.requestVerb, EndpointCatalog.concretePath(doc), Map.empty) @@ -207,8 +207,8 @@ class AuthSweepTest extends ServerSetupWithTestData with DefaultUsers with Sweep if (code != 401) None else if (msg.startsWith(ApplicationNotIdentified.take(9))) { info(s"${describe(doc)} -- declares no user authentication and requires an APPLICATION " + - s"instead ($msg). The doc is accurate about the user; consider authMode = " + - s"ApplicationOnly so resource-docs can say so too.") + s"instead ($msg). The doc is accurate about the user; consider listing " + + s"ApplicationNotIdentified in its error list so resource-docs can say so too.") None } else Some(s"${describe(doc)} -- declares no authentication requirement yet answered 401 " + diff --git a/obp-api/src/test/scala/code/api/sweep/EndpointCatalog.scala b/obp-api/src/test/scala/code/api/sweep/EndpointCatalog.scala index ae4308bf41..30b6ecde11 100644 --- a/obp-api/src/test/scala/code/api/sweep/EndpointCatalog.scala +++ b/obp-api/src/test/scala/code/api/sweep/EndpointCatalog.scala @@ -27,8 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.api.sweep -// EndpointAuthMode and its four cases are members of the APIUtil object, not of the package. -import code.api.util.APIUtil.{ResourceDoc, UserOnly} +import code.api.util.APIUtil.ResourceDoc import code.api.util.ErrorMessages.$AuthenticatedUserIsRequired import code.api.util.ApiTag @@ -77,9 +76,9 @@ object EndpointCatalog { sealed abstract class SkipReason(val why: String) case object DynamicDoc extends SkipReason( "tagged apiTagDynamic: created per-database, so its presence is machine state, not contract") - case object NonUserAuthMode extends SkipReason( - "authMode is not UserOnly: an anonymous call may legitimately not be 401 " + - "(ApplicationOnly drops AuthenticatedUserIsRequired entirely). EndpointAuthModeTest covers these.") + case object ApplicationRequired extends SkipReason( + "an identified application is required and a user is optional (ResourceDoc.requiresApplicationButNotUser): " + + "an anonymous call is refused as an unknown application, not as a missing user. EndpointAccessRuleTest covers these.") case object AutoValidateRolesOff extends SkipReason( "disableAutoValidateRoles(): roles stay in the doc for the catalog but the framework does " + "not enforce them, so asserting 403 would assert something no code promises") @@ -87,7 +86,7 @@ object EndpointCatalog { /** Skip reason, or None when the endpoint is in scope for the auth sweep. */ def skipReason(doc: ResourceDoc): Option[SkipReason] = if (doc.tags.contains(ApiTag.apiTagDynamic)) Some(DynamicDoc) - else if (doc.authMode != UserOnly) Some(NonUserAuthMode) + else if (doc.requiresApplicationButNotUser) Some(ApplicationRequired) else None /** Skip reason for the role dimension specifically — a superset of skipReason. */ diff --git a/obp-api/src/test/scala/code/api/sweep/FailureSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/FailureSweepTest.scala index 2f08271d8d..ebd633e08b 100644 --- a/obp-api/src/test/scala/code/api/sweep/FailureSweepTest.scala +++ b/obp-api/src/test/scala/code/api/sweep/FailureSweepTest.scala @@ -152,7 +152,10 @@ class FailureSweepTest extends ServerSetupWithTestData with DefaultUsers with Sw "OBPv7.0.0-createTestEmail" -> ("500 OBP-10056: refuses to send because portal_external_url " + "is unset, which is true of any test rig. Environmental -- but a missing configuration is " + "a 503, not a 500, so the status itself is logged in REGRESSION-GAPS rather than accepted " + - "as correct") + "as correct"), + "OBPv7.0.0-sendEmailToCustomer" -> ("503 OBP-30606: no From address is set for emails to Customers, " + + "which is true of any test rig. A missing configuration is a 503, so this is the right answer. The " + + "sweep reached it once endpoint auth modes were removed: it used to skip UserXORApplication endpoints") ) private lazy val inScope: List[ResourceDoc] = FailureSweepTest.scope diff --git a/obp-api/src/test/scala/code/api/util/http4s/UserOptionalHandlerTest.scala b/obp-api/src/test/scala/code/api/util/http4s/UserOptionalHandlerTest.scala new file mode 100644 index 0000000000..8b195779d8 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/UserOptionalHandlerTest.scala @@ -0,0 +1,182 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.util.http4s + +import code.api.util.APIUtil.ResourceDoc +import code.setup.ServerSetup +import org.scalatest.Tag + +import java.io.File +import scala.io.Source + +/** + * An endpoint whose error list says that a user is optional must have a handler that works without one. + * + * ResourceDoc.requiresApplicationButNotUser is true when the error list names ApplicationNotIdentified but not + * AuthenticatedUserIsRequired, and the doc has no Role. The middleware then lets an identified application in + * with no user, as the Berlin Group and UK Open Banking consent and payment endpoints need. If the handler then + * insists on a user anyway (an EndpointHelpers.withUser... helper, or opening the call context's user with + * AuthenticatedUserIsRequired), the endpoint never works for that caller, and nothing else notices: calls with a + * user still pass. + * + * The check reads each handler's source. It sees the handler body only, not the functions it calls. + */ +class UserOptionalHandlerTest extends ServerSetup { + + object UserOptionalHandlerTag extends Tag("UserOptionalHandler") + + private def catalogs: List[Iterable[ResourceDoc]] = List( + code.api.v1_2_1.Http4s121.Implementations1_2_1.orderedResourceDocs, + code.api.v1_3_0.Http4s130.Implementations1_3_0.orderedResourceDocs, + code.api.v1_4_0.Http4s140.Implementations1_4_0.orderedResourceDocs, + code.api.v2_0_0.Http4s200.Implementations2_0_0.orderedResourceDocs, + code.api.v2_1_0.Http4s210.Implementations2_1_0.orderedResourceDocs, + code.api.v2_2_0.Http4s220.Implementations2_2_0.orderedResourceDocs, + code.api.v3_0_0.Http4s300.Implementations3_0_0.orderedResourceDocs, + code.api.v3_1_0.Http4s310.Implementations3_1_0.orderedResourceDocs, + code.api.v4_0_0.Http4s400.Implementations4_0_0.orderedResourceDocs, + code.api.v5_0_0.Http4s500.Implementations5_0_0.orderedResourceDocs, + code.api.v5_1_0.Http4s510.Implementations5_1_0.orderedResourceDocs, + code.api.v6_0_0.Http4s600.Implementations6_0_0.orderedResourceDocs, + code.api.v7_0_0.Http4s700.Implementations7_0_0.orderedResourceDocs, + code.api.berlin.group.v1_3.Http4sBGv13.orderedResourceDocs, + code.api.berlin.group.v2.Http4sBGv2.orderedResourceDocs, + code.api.UKOpenBanking.v2_0_0.Http4sUKOBv200.orderedResourceDocs, + code.api.UKOpenBanking.v3_1_0.Http4sUKOBv310.orderedResourceDocs, + code.api.UKOpenBanking.v4_0_1.Http4sUKOBv401.orderedResourceDocs + ) + + private lazy val userOptionalDocs: List[ResourceDoc] = + catalogs.flatten.filter(_.requiresApplicationButNotUser).distinctBy(d => (d.implementedInApiVersion, d.partialFunctionName)) + + private def mainSourceRoot: File = + List(new File("src/main/scala/code"), new File("obp-api/src/main/scala/code")) + .find(_.isDirectory) + .getOrElse(fail("cannot locate the main sources - this guard must not pass by failing to look")) + + private def scalaFilesUnder(dir: File): List[File] = + Option(dir.listFiles).toList.flatten.flatMap { f => + if (f.isDirectory) scalaFilesUnder(f) else if (f.getName.endsWith(".scala")) List(f) else Nil + } + + // Comments are blanked, so a helper named in a comment is not taken for a call + private lazy val sources: List[(File, String)] = + scalaFilesUnder(new File(mainSourceRoot, "api")).map { f => + val s = Source.fromFile(f, "UTF-8") + try f -> s.mkString.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)^\\s*//[^\n]*", " ") + finally s.close() + } + + /** The source files of the doc's own version: the same route name is used in several versions. */ + private def sourcesOf(doc: ResourceDoc): List[String] = { + val version = doc.implementedInApiVersion + val dir = version.apiShortVersion.replace('.', '_') + val standardDir = version.apiStandard match { + case "BG" => "code/api/berlin/group" + case "UK" => "code/api/UKOpenBanking" + case _ => "code/api" + } + sources.collect { + case (f, src) if f.getParentFile.getName.startsWith(dir) && + f.getParentFile.getParentFile.getPath.replace('\\', '/').endsWith(standardDir) => src + } + } + + /** The text from the brace at `open` to its matching close, skipping braces inside string literals. */ + private def braceBlock(source: String, open: Int): Option[String] = { + var depth = 0 + var i = open + while (i < source.length) { + if (source.startsWith("\"\"\"", i)) { + val end = source.indexOf("\"\"\"", i + 3) + if (end < 0) return None + i = end + 3 + } else { + source.charAt(i) match { + case '"' => + var j = i + 1 + while (j < source.length && source.charAt(j) != '"') j += (if (source.charAt(j) == '\\') 2 else 1) + i = j + 1 + case '{' => depth += 1; i += 1 + case '}' => + depth -= 1 + if (depth == 0) return Some(source.substring(open, i + 1)) + i += 1 + case _ => i += 1 + } + } + } + None + } + + /** The route the doc names in `http4sPartialFunction = Some(route)`, read from the ResourceDoc that names the doc. */ + private def routeNameOf(doc: ResourceDoc): Option[String] = { + val name = java.util.regex.Pattern.quote(doc.partialFunctionName) + val named = s"""(?:nameOf\\(\\s*(?:\\w+\\.)*$name\\s*\\)|"$name")""".r + val route = """http4sPartialFunction\s*=\s*Some\(\s*(\w+)\s*\)""".r + sourcesOf(doc).iterator.flatMap { src => + named.findAllMatchIn(src).flatMap { m => + val docStart = src.lastIndexOf("ResourceDoc(", m.start) + val nextDoc = src.indexOf("ResourceDoc(", m.end) match { case -1 => src.length; case n => n } + if (docStart < 0) None else route.findFirstMatchIn(src.substring(m.end, nextDoc)).map(_.group(1)) + } + }.toSeq.headOption + } + + private def handlerOf(doc: ResourceDoc, route: String): Option[String] = { + val definition = s"""val\\s+$route\\s*(?::\\s*Http4sRoute\\s*)?=\\s*Http4sRoute\\s*\\{""".r + sourcesOf(doc).iterator.flatMap { src => + definition.findFirstMatchIn(src).flatMap(m => braceBlock(src, m.end - 1)) + }.toSeq.headOption + } + + private val needsUser = """EndpointHelpers\s*\.\s*withUser\w*|\.user\s*\.\s*openOrThrowException\(\s*\$?AuthenticatedUserIsRequired""".r + + feature("A handler whose doc says a user is optional works without a user") { + + scenario("the docs that need only an identified application are found", UserOptionalHandlerTag) { + // The Berlin Group and UK Open Banking consent and payment endpoints, and the v7.0.0 consumers/current ones + userOptionalDocs.size should be >= 20 + } + + scenario("none of their handlers insists on a user", UserOptionalHandlerTag) { + val problems = userOptionalDocs.flatMap { doc => + val label = s"${doc.implementedInApiVersion} ${doc.partialFunctionName}" + routeNameOf(doc) match { + case None => Some(s"$label: no http4sPartialFunction found for its ResourceDoc") + case Some(route) => handlerOf(doc, route) match { + case None => Some(s"$label: the route $route was not found") + case Some(body) => needsUser.findFirstIn(body).map(hit => s"$label: the handler of $route requires a user ($hit)") + } + } + } + withClue(s"\n${problems.mkString("\n")}\n") { + problems shouldBe empty + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v4_0_0/UserCustomerLinkTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/UserCustomerLinkTest.scala index 8e273ed0f3..307179e433 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/UserCustomerLinkTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/UserCustomerLinkTest.scala @@ -143,8 +143,7 @@ class UserCustomerLinkTest extends V400ServerSetup { val createResponse = makePostRequest(createRequest, write(postJson)) Then("We should get a 401") createResponse.code should equal(401) - // The endpoint is UserXORApplication: with neither a User nor an application, the application is unknown. - createResponse.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + createResponse.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } } feature(s"test $ApiEndpoint4 version $VersionOfApi - Authorized access") { diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala index 0a9de956fb..0284b36aef 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala @@ -88,9 +88,8 @@ class ConsumerTest extends V510ServerSetup { responseApiEndpoint3.code should equal(401) responseApiEndpoint4.code should equal(401) responseApiEndpoint6.code should equal(401) - // createConsumer and getConsumers use authMode=UserXORApplication, so unauthenticated requests get ApplicationNotIdentified - responseApiEndpoint1.body.toString contains(s"$ApplicationNotIdentified") should be (true) - responseApiEndpoint2.body.toString contains(s"$ApplicationNotIdentified") should be (true) + responseApiEndpoint1.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) + responseApiEndpoint2.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) responseApiEndpoint3.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) responseApiEndpoint4.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) responseApiEndpoint6.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) diff --git a/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala index f0d782746c..910326a0dd 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala @@ -30,7 +30,6 @@ package code.api.v5_1_0 import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID import code.api.util.APIUtil import code.api.util.APIUtil.OAuth._ -import code.api.util.APIUtil.UserOnly import code.api.util.ApiRole import code.api.util.ApiRole.{CanCreateEntitlementAtAnyBank, CanCreateEntitlementAtOneBank, CanGetAnyUser, CanGetMetricsAtOneBank} import code.api.util.ErrorMessages.UserHasMissingRoles @@ -144,8 +143,8 @@ class JustInTimeEntitlementsTest extends V510ServerSetup with DefaultUsers { DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateEntitlementAtOneBank.toString) When("access control is asked about a Role in the system space") - val allowedInSystemSpace = APIUtil.handleAccessControlWithAuthMode( - DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "", List(wantedRole), UserOnly) + val allowedInSystemSpace = APIUtil.handleAccessControlRegardingEntitlementsAndScopes( + DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "", List(wantedRole)) Then("it refuses") allowedInSystemSpace should equal(false) @@ -163,8 +162,8 @@ class JustInTimeEntitlementsTest extends V510ServerSetup with DefaultUsers { bankId, resourceUser2.userId, CanCreateEntitlementAtOneBank.toString) When("access control is asked about the same Role at a real bank") - val allowedAtBank = APIUtil.handleAccessControlWithAuthMode( - bankId, resourceUser2.userId, "", List(wantedRole), UserOnly) + val allowedAtBank = APIUtil.handleAccessControlRegardingEntitlementsAndScopes( + bankId, resourceUser2.userId, "", List(wantedRole)) Then("it allows, which is the behaviour the system space rule must not have broken") allowedAtBank should equal(true) @@ -199,8 +198,8 @@ class JustInTimeEntitlementsTest extends V510ServerSetup with DefaultUsers { } When("access control is asked about a system scoped Role on an endpoint that carries that bank id") - val allowed = APIUtil.handleAccessControlWithAuthMode( - bankId, resourceUser3.userId, "", List(systemScopedRole), UserOnly) + val allowed = APIUtil.handleAccessControlRegardingEntitlementsAndScopes( + bankId, resourceUser3.userId, "", List(systemScopedRole)) Then("it refuses, because Add Entitlement would have refused the same grant") allowed should equal(false) @@ -222,8 +221,8 @@ class JustInTimeEntitlementsTest extends V510ServerSetup with DefaultUsers { "", resourceUser1.userId, CanCreateEntitlementAtAnyBank.toString) When("access control is asked about a system scoped Role on an endpoint that carries a bank id") - val allowed = APIUtil.handleAccessControlWithAuthMode( - bankId, resourceUser1.userId, "", List(systemScopedRole), UserOnly) + val allowed = APIUtil.handleAccessControlRegardingEntitlementsAndScopes( + bankId, resourceUser1.userId, "", List(systemScopedRole)) Then("it allows") allowed should equal(true) @@ -249,8 +248,8 @@ class JustInTimeEntitlementsTest extends V510ServerSetup with DefaultUsers { } When("access control is asked about the Role that grants Entitlements everywhere") - val allowed = APIUtil.handleAccessControlWithAuthMode( - bankId, resourceUser3.userId, "", List(ApiRole.canCreateEntitlementAtAnyBank), UserOnly) + val allowed = APIUtil.handleAccessControlRegardingEntitlementsAndScopes( + bankId, resourceUser3.userId, "", List(ApiRole.canCreateEntitlementAtAnyBank)) Then("it refuses: the granting Roles are excluded from this automation, as the Glossary and the props template both say") allowed should equal(false) diff --git a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala index 914b86ac6d..cb002c3191 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala @@ -30,7 +30,7 @@ package code.api.v5_1_0 import org.json4s._ import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.{CanGetSystemLogCacheAll,CanGetSystemLogCacheError,CanGetSystemLogCacheInfo} -import code.api.util.ErrorMessages.{UserHasMissingRoles, ApplicationNotIdentified} +import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired} import code.api.v5_1_0.Http4s510.Implementations5_1_0 import code.entitlement.Entitlement import code.scope.Scope @@ -58,7 +58,7 @@ class LogCacheEndpointTest extends V510ServerSetup { val response = makeGetRequest(request) Then("We should get a 401") response.code should equal(401) - response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala index 75808c1eae..c98fcf6f7c 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/UserTest.scala @@ -66,8 +66,8 @@ class UserTest extends V510ServerSetup { val response400 = makeGetRequest(request400) Then("We should get a 401") response400.code should equal(401) - // UserXORApplication: a caller with no credentials is refused as an unidentified application - response400.body.extract[ErrorMessage].message should include("OBP-20200") + // The endpoint has a Role, so a caller with no credentials is refused as not logged in + response400.body.extract[ErrorMessage].message should include("OBP-20001") } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala index 1796c96b42..cf226e811c 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala @@ -30,7 +30,7 @@ package code.api.v6_0_0 import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.CanReadAggregateMetrics import code.api.util.APIUtil -import code.api.util.ErrorMessages.{AggregateMetricsDateRangeTooLong, ApplicationNotIdentified, UserHasMissingRoles} +import code.api.util.ErrorMessages.{AggregateMetricsDateRangeTooLong, AuthenticatedUserIsRequired, UserHasMissingRoles} import code.entitlement.Entitlement import code.metrics.{MetricBatchWriter, MetricsProps} import code.scope.Scope @@ -60,7 +60,7 @@ class AggregateMetricsTest extends V600ServerSetup { val response = makeGetRequest(request) Then("We should get a 401") response.code should equal(401) - response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } } @@ -138,7 +138,7 @@ class AggregateMetricsTest extends V600ServerSetup { try { Then("the application acting as itself gets aggregate metrics") makeGetRequest(aggregateMetrics.GET <@ (application2)).code should equal(200) - And("user2, a person signing with testConsumer2, still needs an Entitlement of their own (UserXORApplication)") + And("user2, a person signing with testConsumer2, still needs an Entitlement of their own") makeGetRequest(aggregateMetrics.GET <@ (user2)).code should equal(403) } finally Scope.scope.vend.deleteScope(granted) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala index 5f95b1f6ae..eb7fe17596 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala @@ -42,10 +42,9 @@ import org.scalatest.Tag import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID /** - * auth_mode on a Dynamic Entity decides who may hold the roles guarding its data endpoints: - * the User's Entitlements (UserOnly, the default), the Consumer's Scopes (ApplicationOnly), - * the Consumer's Scopes when it acts as itself and the User's Entitlements when a person acts (UserXORApplication), - * or both (UserAndApplication). + * auth_mode on a Dynamic Entity is gone. A client that still sends it is not refused: the field is ignored. + * The entity's data endpoints work like static endpoints: a person needs the entity's Role as an Entitlement, + * and a Platform App acting as itself needs it as a Scope. */ class DynamicEntityAuthModeTest extends V600ServerSetup { // user1 defines the entities (and may hold broad roles in the shared test DB); @@ -86,53 +85,58 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { feature("auth_mode on the entity definition") { - scenario("defaults to UserOnly and is returned on the definition", VersionOfApi) { + scenario("is not returned on the definition", VersionOfApi) { val (code, body) = createSystemEntity(entityJson("am_default", None)) code should equal(201) - (body \ "auth_mode").extract[String] should equal("UserOnly") + (body \ "auth_mode") should equal(JNothing) deleteSystemEntity((body \ "dynamic_entity_id").extract[String]) } - scenario("accepts UserXORApplication and returns it", VersionOfApi) { - val (code, body) = createSystemEntity(entityJson("am_either", Some("UserXORApplication"))) - code should equal(201) - (body \ "auth_mode").extract[String] should equal("UserXORApplication") - deleteSystemEntity((body \ "dynamic_entity_id").extract[String]) + scenario("is accepted and ignored when an older client still sends it, whatever its value", VersionOfApi) { + for ((name, mode) <- List("am_either" -> "UserXORApplication", "am_bad" -> "Nobody")) { + val (code, body) = createSystemEntity(entityJson(name, Some(mode))) + code should equal(201) + (body \ "auth_mode") should equal(JNothing) + deleteSystemEntity((body \ "dynamic_entity_id").extract[String]) + } } - scenario("accepts the former name UserOrApplication and stores it as UserXORApplication", VersionOfApi) { - val (code, body) = createSystemEntity(entityJson("am_legacy", Some("UserOrApplication"))) + scenario("ApplicationOnly on a personal entity is accepted too", VersionOfApi) { + val (code, body) = createSystemEntity(entityJson("am_personal_app", Some("ApplicationOnly"), personal = true)) code should equal(201) - (body \ "auth_mode").extract[String] should equal("UserXORApplication") deleteSystemEntity((body \ "dynamic_entity_id").extract[String]) } - - scenario("rejects an unknown value", VersionOfApi) { - val (code, body) = createSystemEntity(entityJson("am_bad", Some("Nobody"))) - code should equal(400) - (body \ "message").extract[String] should include(DynamicEntityInstanceValidateFail) - } - - scenario("rejects ApplicationOnly on a personal entity", VersionOfApi) { - val (code, body) = createSystemEntity(entityJson("am_personal_app", Some("ApplicationOnly"), personal = true)) - code should equal(400) - (body \ "message").extract[String] should include("ApplicationOnly") - } } - feature("auth_mode on the entity's data endpoints") { + feature("the entity's data endpoints work like static endpoints, whatever auth_mode says") { - scenario("UserOnly (default): a Consumer Scope alone is not enough", VersionOfApi) { + scenario("no auth_mode: a Platform App acting as itself is let in on its Scope", VersionOfApi) { val entityName = "am_useronly_data" val (code, body) = createSystemEntity(entityJson(entityName, None)) code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) try { - val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) + makeGetRequest((dynamicEntity_Request / entityName).GET <@(application2)).code should equal(200) + } finally { + Scope.scope.vend.deleteScope(scope) + deleteSystemEntity(entityId) + } + } + + scenario("a Consumer that is not a Platform App does not get its Scope", VersionOfApi) { + val entityName = "am_not_platform" + val (code, body) = createSystemEntity(entityJson(entityName, Some("UserXORApplication"))) + code should equal(201) + val entityId = (body \ "dynamic_entity_id").extract[String] + val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) + _root_.code.platformapp.PlatformApps.platformAppProvider.vend.deletePlatformApp(testConsumer2.consumerId.get) + try { + val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(Some((consumer2, tokenApplication2)))) response.code should equal(403) response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) } finally { + ensurePlatformApp(testConsumer2) Scope.scope.vend.deleteScope(scope) deleteSystemEntity(entityId) } @@ -199,20 +203,14 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { } } - scenario("UserAndApplication: needs both the Entitlement and the Scope", VersionOfApi) { + scenario("UserAndApplication no longer asks for the Scope as well: the Entitlement is enough", VersionOfApi) { val entityName = "am_both_data" val (code, body) = createSystemEntity(entityJson(entityName, Some("UserAndApplication"))) code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] try { Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, getRoleName(entityName)) - makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(403) - val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) - try { - makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(200) - } finally { - Scope.scope.vend.deleteScope(scope) - } + makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(200) } finally { deleteSystemEntity(entityId) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala index 91eefb4cdc..5a21351a40 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -179,8 +179,8 @@ class DynamicEntityTest extends V600ServerSetup { val response = makePostRequest(request, write(rightEntityV600)) Then("We should get a 401") response.code should equal(401) - And("error should be " + ApplicationNotIdentified) - response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + And("error should be " + AuthenticatedUserIsRequired) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } scenario("Create System Dynamic Entity - without proper role", ApiEndpoint1, VersionOfApi) { @@ -194,7 +194,7 @@ class DynamicEntityTest extends V600ServerSetup { } scenario("Create System Dynamic Entity with consumer scope (no user entitlement)", ApiEndpoint1, VersionOfApi) { - // Add scope to consumer instead of entitlement to user — UserXORApplication accepts it when the Consumer acts as itself + // Add scope to consumer instead of entitlement to user — it counts when the Consumer, a Platform App, acts as itself val addedScope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer.id.get.toString, ApiRole.CanCreateDynamicEntityDefinition.toString) When("We create a dynamic entity using consumer with scope") diff --git a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAccessRuleTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAccessRuleTest.scala new file mode 100644 index 0000000000..5b0b28e8b5 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAccessRuleTest.scala @@ -0,0 +1,120 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v6_0_0 + +import code.api.util.APIUtil._ +import code.api.util.ApiRole +import code.api.util.ErrorMessages._ +import code.entitlement.Entitlement +import code.platformapp.PlatformApps +import code.scope.Scope +import code.users.Users +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * Who may call an endpoint, and where its Roles come from. + * + * - The error list says who may call: "User not logged in" or Roles means a user is required. "The application + * cannot be identified" without those means an identified application is required, and a user is optional. + * - A person needs a Role as an Entitlement. An application acting as itself (its Consumer is a Platform App, and + * it calls as its own client-credentials user) needs it as a Scope. + */ +class EndpointAccessRuleTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + + private val role = ApiRole.canGetAnyUser + private def ownUserId(c: code.model.Consumer): String = { + tokenApplication2 // creates the Consumer's own client-credentials user + Users.users.vend.getUserByProviderId(code.api.OAuth2Login.OBPOIDC.clientCredentialsProvider, c.key.get) + .map(_.userId).openOrThrowException("the application's own user should exist") + } + private def consumerPk(c: code.model.Consumer): String = c.id.get.toString + private def allowed(userId: String, c: code.model.Consumer): Boolean = + handleAccessControlRegardingEntitlementsAndScopes("", userId, consumerPk(c), List(role)) + + feature("The error list says who may call") { + + scenario("a consent endpoint needs an identified application but no user", VersionOfApi) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(code.api.berlin.group.ConstantsBG.berlinGroupVersion1, "createConsent"))) + docs should not be empty + docs.foreach { doc => + doc.requiresApplicationButNotUser shouldBe true + doc.errorResponseBodies should contain(ApplicationNotIdentified) + doc.errorResponseBodies should not contain AuthenticatedUserIsRequired + } + } + + scenario("an endpoint with Roles needs a user", VersionOfApi) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.v6_0_0, "verifyUserCredentials"))) + docs should not be empty + docs.foreach { doc => + doc.requiresApplicationButNotUser shouldBe false + doc.errorResponseBodies should contain(AuthenticatedUserIsRequired) + } + } + + scenario("a public endpoint needs neither", VersionOfApi) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.v6_0_0, "root"))) + docs should not be empty + docs.foreach(_.requiresApplicationButNotUser shouldBe false) + } + } + + feature("Where a Role comes from") { + + scenario("no Role needed: always allowed", VersionOfApi) { + handleAccessControlRegardingEntitlementsAndScopes("", "", "", Nil) shouldBe true + } + + scenario("a Platform App acting as itself is allowed on its Scope, and only on its Scope", VersionOfApi) { + ensurePlatformApp(testConsumer2) + val appUser = ownUserId(testConsumer2) + allowed(appUser, testConsumer2) shouldBe false + Scope.scope.vend.addScope("", consumerPk(testConsumer2), role.toString) + allowed(appUser, testConsumer2) shouldBe true + } + + scenario("a Consumer that is not a Platform App does not get its Scope", VersionOfApi) { + val appUser = ownUserId(testConsumer2) + PlatformApps.platformAppProvider.vend.deletePlatformApp(testConsumer2.consumerId.get) + Scope.scope.vend.addScope("", consumerPk(testConsumer2), role.toString) + allowed(appUser, testConsumer2) shouldBe false + ensurePlatformApp(testConsumer2) + } + + scenario("a person needs their own Entitlement, not the Scope of the Consumer they call through", VersionOfApi) { + ensurePlatformApp(testConsumer2) + Scope.scope.vend.addScope("", consumerPk(testConsumer2), role.toString) + allowed(resourceUser2.userId, testConsumer2) shouldBe false + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, role.toString) + allowed(resourceUser2.userId, testConsumer2) shouldBe true + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala deleted file mode 100644 index e86bb20a6b..0000000000 --- a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala +++ /dev/null @@ -1,83 +0,0 @@ -/** -Open Bank Project - API -Copyright (C) 2011-2026, TESOBE GmbH. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -Email: contact@tesobe.com -TESOBE GmbH. -Osloer Strasse 16/17 -Berlin 13359, Germany - -This product includes software developed at -TESOBE (http://www.tesobe.com/) - - */ - -package code.api.v6_0_0 - -import code.api.util.APIUtil._ -import code.api.util.ErrorMessages._ -import com.openbankproject.commons.util.ApiVersion -import org.scalatest.Tag - -/** - * Unit tests for EndpointAuthMode sealed trait and its integration with ResourceDoc. - * Tests that the auth mode values are correctly defined and accessible. - */ -class EndpointAuthModeTest extends V600ServerSetup { - - object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) - - feature("EndpointAuthMode sealed trait") { - - scenario("All four auth modes should be defined", VersionOfApi) { - val userOnly: EndpointAuthMode = UserOnly - val appOnly: EndpointAuthMode = ApplicationOnly - val userOrApp: EndpointAuthMode = UserXORApplication - val userAndApp: EndpointAuthMode = UserAndApplication - - userOnly shouldBe a[EndpointAuthMode] - appOnly shouldBe a[EndpointAuthMode] - userOrApp shouldBe a[EndpointAuthMode] - userAndApp shouldBe a[EndpointAuthMode] - } - - scenario("verifyUserCredentials ResourceDoc should have UserXORApplication authMode", VersionOfApi) { - val operationId = buildOperationId(ApiVersion.v6_0_0, "verifyUserCredentials") - val docs = ResourceDoc.getResourceDocs(List(operationId)) - - docs should not be empty - docs.foreach { doc => - doc.authMode should equal(UserXORApplication) - doc.errorResponseBodies should contain(ApplicationNotIdentified) - } - } - - scenario("Default authMode should be UserOnly for existing endpoints", VersionOfApi) { - val operationId = buildOperationId(ApiVersion.v6_0_0, "root") - val docs = ResourceDoc.getResourceDocs(List(operationId)) - - docs should not be empty - docs.foreach { doc => - doc.authMode should equal(UserOnly) - } - } - - scenario("handleAccessControlWithAuthMode should pass for empty roles", VersionOfApi) { - val result = handleAccessControlWithAuthMode("", "", "", Nil, UserOnly) - result should equal(true) - } - } -} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala index f40c4af728..7cdc858d1e 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala @@ -54,7 +54,7 @@ class GetOidcClientTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala index 70b413c0cc..d5a4244402 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala @@ -54,8 +54,8 @@ class GetUserByUserIdTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) - And("The error message should indicate application not identified (UserXORApplication mode requires at least app auth)") - response.body.extract[ErrorMessage].message should include("OBP-20200") + And("The error message should say the user is not logged in") + response.body.extract[ErrorMessage].message should include("OBP-20001") } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala index 65cf018ae0..cada12a426 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala @@ -62,8 +62,8 @@ class GetUsersTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) - And("The error message should indicate application not identified (UserXORApplication mode requires at least app auth)") - response.body.extract[ErrorMessage].message should include("OBP-20200") + And("The error message should say the user is not logged in") + response.body.extract[ErrorMessage].message should include("OBP-20001") } scenario("Authenticated user without CanGetAnyUser role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala index 3a42bf51a9..9dc588c04d 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/UserLookupApplicationAccessTest.scala @@ -37,7 +37,7 @@ import org.scalatest.Tag /** * The user lookups an identity provider needs (by provider and username, by user id, and the list) admit an * application acting as itself on its Consumer's Scope CanGetAnyUser, as well as a person with the Entitlement. - * A person calling through that Consumer does not get its Scope. + * The application's Consumer must be a Platform App. A person calling through that Consumer does not get its Scope. */ class UserLookupApplicationAccessTest extends V600ServerSetup { @@ -77,6 +77,25 @@ class UserLookupApplicationAccessTest extends V600ServerSetup { } } + scenario("an application whose Consumer is not a Platform App does not get its Scope", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, VersionOfApi) { + withScope { + makeGetRequest(byUserId <@ (application2)).code should equal(200) // fixtures mark it as a Platform App + val consumerId = testConsumer2.consumerId.get + code.platformapp.PlatformApps.platformAppProvider.vend.deletePlatformApp(consumerId) + try { + // The token itself, without application2, which would mark the Consumer again + val notMarked = Some((consumer2, tokenApplication2)) + lookups.foreach { case (name, request) => + withClue(name) { + val response = makeGetRequest(request() <@ (notMarked)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + } + } finally ensurePlatformApp(testConsumer2) + } + } + scenario("a person calling through the Consumer does not get its Scope", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, VersionOfApi) { withScope { lookups.foreach { case (name, request) => diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala index 88e14a6c5a..65f17022d9 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala @@ -60,7 +60,7 @@ class VerifyOidcClientTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala index 1c17498a48..a9f8a5cc8a 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala @@ -109,8 +109,8 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) - And("The error message should indicate application not identified (UserXORApplication mode requires at least app auth)") - response.body.extract[ErrorMessage].message should include("OBP-20200") + And("The error message should say the user is not logged in") + response.body.extract[ErrorMessage].message should include("OBP-20001") } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { @@ -130,7 +130,7 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { } scenario("Successfully verify valid credentials with consumer scope, the Consumer acting as itself (no user entitlement)", ApiEndpoint, VersionOfApi) { - // Add scope to consumer instead of entitlement to user — UserXORApplication accepts it when the Consumer acts as itself + // Add scope to consumer instead of entitlement to user — it counts when the Consumer, a Platform App, acts as itself val addedScope = Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanVerifyUserCredentials.toString) When("We verify valid credentials using consumer with scope") diff --git a/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala index 969d3aaf5b..ee83ab9488 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerGrantTypesTest.scala @@ -86,16 +86,16 @@ class ConsumerGrantTypesTest extends V600ServerSetup { ConsumerGrantTypes.oauth2GrantTypes(notSet) should equal(ConsumerGrantTypes.DefaultOAuth2) val applicationOnly = consumer("client_credentials") - ConsumerGrantTypes.isClientCredentialsOnly(applicationOnly) should be(true) + ConsumerGrantTypes.allows(applicationOnly, ConsumerGrantTypes.ClientCredentials) should be(true) ConsumerGrantTypes.allows(applicationOnly, ConsumerGrantTypes.DirectLogin) should be(false) val peopleOnly = consumer("authorization_code,refresh_token,direct_login") - ConsumerGrantTypes.excludesClientCredentials(peopleOnly) should be(true) + ConsumerGrantTypes.allows(peopleOnly, ConsumerGrantTypes.ClientCredentials) should be(false) ConsumerGrantTypes.oauth2GrantTypes(peopleOnly) should equal(List("authorization_code", "refresh_token")) val both = consumer("authorization_code,client_credentials") - ConsumerGrantTypes.isClientCredentialsOnly(both) should be(false) - ConsumerGrantTypes.excludesClientCredentials(both) should be(false) + ConsumerGrantTypes.allows(both, ConsumerGrantTypes.ClientCredentials) should be(true) + ConsumerGrantTypes.allows(both, ConsumerGrantTypes.DirectLogin) should be(false) } scenario("client-credentials tokens are recognised", VersionOfApi) { @@ -107,14 +107,13 @@ class ConsumerGrantTypesTest extends V600ServerSetup { ConsumerGrantTypes.isClientCredentialsToken(jwt("sub" -> "9b1c", "azp" -> "app", "preferred_username" -> "alice")) should be(false) } - scenario("a Consumer's grant types decide whether the application acts as itself", VersionOfApi) { + scenario("grant types do not decide whether the application acts as itself", VersionOfApi) { + ensurePlatformApp(testConsumer2) val consumerPk = testConsumer2.id.get.toString - isApplicationActingAsItself(resourceUser2.userId, consumerPk) should be(false) withGrantTypes("client_credentials") { - isApplicationActingAsItself(resourceUser2.userId, consumerPk) should be(true) - } - withGrantTypes("authorization_code") { - isApplicationActingAsItself("", consumerPk) should be(false) + // A person's user is still a person: grant types only control how tokens are obtained. + isApplicationActingAsItself(resourceUser2.userId, consumerPk) should be(false) + isApplicationActingAsItself("", consumerPk) should be(true) } } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala index 69648bef33..e0980c8563 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala @@ -205,7 +205,7 @@ class DynamicEntityDefinitionTest extends ServerSetupWithTestData { scenario("a Consumer holding the Role as a Scope may list and update definitions", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { // A service (the Portal, the API Manager) ensures its entities at startup with a client-credentials - // token: no User, so the Roles must be accepted as Scopes on its Consumer (auth mode UserXORApplication). + // token, so the Roles must be accepted as Scopes on its Consumer, which is a Platform App. // user2 holds none of these Roles and signs with testConsumer2, which gets the Scopes; application2 is // testConsumer2 acting as itself. val entityName = newEntityName() diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala index 281862b9fa..1fcc37f446 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala @@ -29,7 +29,7 @@ package code.api.v7_0_0 import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.canGetConsumers -import code.api.util.ErrorMessages.{ApplicationNotIdentified, UserHasMissingRoles} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} import code.api.v6_0_0.V600ServerSetup import code.api.v7_0_0.JSONFactory700.ConsumersJsonV700 import code.entitlement.Entitlement @@ -49,7 +49,7 @@ class GetConsumersTest extends V600ServerSetup { val response = makeGetRequest((v7_0_0_Request / "management" / "consumers").GET) Then("We should get a 401") response.code should equal(401) - response.body.extract[ErrorMessage].message should include(ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should include(AuthenticatedUserIsRequired) } scenario("A user without the Role is refused", ApiEndpoint1, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala index a03b697511..d8d3d3fc0a 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala @@ -65,7 +65,7 @@ class GetOidcConsentTest extends V600ServerSetup { Then("We should get a 401") response.code should equal(401) - response.body.extract[ErrorMessage].message should equal(ErrorMessages.ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GrantsEndpointTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GrantsEndpointTest.scala index c7586e0011..6c30c54ac3 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/GrantsEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/GrantsEndpointTest.scala @@ -29,7 +29,7 @@ package code.api.v7_0_0 import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.{CanGetAllScopes, CanGetReachableRoles, CanGetTelemetry} -import code.api.util.ErrorMessages.{ApplicationNotIdentified, UserHasMissingRoles} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} import code.api.v6_0_0.V600ServerSetup import code.api.v7_0_0.JSONFactory700.{ReachableRolesJsonV700, ScopeJsonV700, ScopesJsonV700} import code.entitlement.Entitlement @@ -68,7 +68,7 @@ class GrantsEndpointTest extends V600ServerSetup { scenario("Anonymous access fails with 401", GetAllScopes, VersionOfApi) { val response = makeGetRequest(scopesRequest.GET) response.code should equal(401) - response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } scenario("A logged-in user without CanGetAllScopes gets 403", GetAllScopes, VersionOfApi) { @@ -92,7 +92,7 @@ class GrantsEndpointTest extends V600ServerSetup { scenario("Anonymous access fails with 401", GetReachableRoles, VersionOfApi) { val response = makeGetRequest(reachableRequest.GET) response.code should equal(401) - response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } scenario("A logged-in user without CanGetReachableRoles gets 403", GetReachableRoles, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 136647e692..8893c26277 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -3170,7 +3170,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val path = promiseEvidencePath(testBankId1.value, testAccountId0.value, transactionRequestId) val ((personCode, _, _), (statusCode, json, _)) = try { (makeHttpRequestWithBody("POST", path, promiseEvidenceBody(), Map("DirectLogin" -> s"token=${token2.value}")), - makeHttpRequestWithBody("POST", path, promiseEvidenceBody(), Map("DirectLogin" -> s"token=${tokenApplication2.value}"))) + makeHttpRequestWithBody("POST", path, promiseEvidenceBody(), Map("DirectLogin" -> s"token=${ensurePlatformApp(testConsumer2); tokenApplication2.value}"))) } finally code.scope.Scope.scope.vend.deleteScope(granted) Then("user2 does not get the Consumer's Scope") @@ -3188,7 +3188,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("A CanAttachOpenCorridorPromise Scope at another bank does not authorize this bank's URL", Http4s700RoutesTag) { - val headers = Map("DirectLogin" -> s"token=${tokenApplication2.value}") + val headers = Map("DirectLogin" -> s"token=${ensurePlatformApp(testConsumer2); tokenApplication2.value}") val granted = code.scope.Scope.scope.vend.addScope( testBankId2.value, testConsumer2.id.get.toString, canAttachOpenCorridorPromise.toString) val (statusCode, json, _) = try makeHttpRequestWithBody("POST", @@ -3488,7 +3488,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { (makeHttpRequestWithBody("POST", settlementsPath(testBankId1.value), settleBody("EUR"), Map("DirectLogin" -> s"token=${token2.value}")), makeHttpRequestWithBody("POST", settlementsPath(testBankId1.value), settleBody("EUR"), - Map("DirectLogin" -> s"token=${tokenApplication2.value}"))) + Map("DirectLogin" -> s"token=${ensurePlatformApp(testConsumer2); tokenApplication2.value}"))) } finally code.scope.Scope.scope.vend.deleteScope(granted) Then("user2 does not get the Consumer's Scope") @@ -3500,7 +3500,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("A CanSettleOpenCorridor Scope at another bank does not authorize this bank's URL", Http4s700RoutesTag) { - val headers = Map("DirectLogin" -> s"token=${tokenApplication2.value}") + val headers = Map("DirectLogin" -> s"token=${ensurePlatformApp(testConsumer2); tokenApplication2.value}") val granted = code.scope.Scope.scope.vend.addScope( testBankId2.value, testConsumer2.id.get.toString, canSettleOpenCorridor.toString) val (statusCode, json, _) = try makeHttpRequestWithBody("POST", diff --git a/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala index 513c109cfc..4ef014acd9 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala @@ -132,6 +132,8 @@ class PlatformAppsTest extends V600ServerSetup { } finally { scope.foreach(s => Scope.scope.vend.deleteScope(Full(s))) PlatformApps.platformAppProvider.vend.deletePlatformApp(consumerId) + // Other suites use testConsumer2 as a Platform App acting as itself: leave it marked, as it was. + ensurePlatformApp(testConsumer2) } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/TelemetryEndpointTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/TelemetryEndpointTest.scala index 9b86d49b81..a35c3eb002 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/TelemetryEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/TelemetryEndpointTest.scala @@ -30,7 +30,7 @@ package code.api.v7_0_0 import code.api.Constant import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.CanGetTelemetry -import code.api.util.ErrorMessages.{ApplicationNotIdentified, UserHasMissingRoles} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} import code.api.v6_0_0.V600ServerSetup import code.entitlement.Entitlement import code.scope.Scope @@ -63,7 +63,7 @@ class TelemetryEndpointTest extends V600ServerSetup { scenario("Anonymous access fails with 401", ApiEndpoint, VersionOfApi) { val response = makeGetRequest(telemetryRequest.GET) response.code should equal(401) - response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) } scenario("A logged-in user without CanGetTelemetry gets 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/setup/DefaultUsers.scala b/obp-api/src/test/scala/code/setup/DefaultUsers.scala index 48844f7029..7e2e3b6114 100644 --- a/obp-api/src/test/scala/code/setup/DefaultUsers.scala +++ b/obp-api/src/test/scala/code/setup/DefaultUsers.scala @@ -291,10 +291,15 @@ trait DefaultUsers { ).openOrThrowException(attemptedToOpenAnEmptyBox) Token(token.key.get, token.secret.get) } + // The applications that act as themselves in tests are Platform Apps, so their Scopes count for them. The + // database is wiped after every scenario (the Platform App table too), so they are marked again on every use. + def ensurePlatformApp(c: code.model.Consumer): Unit = + if (code.platformapp.PlatformApps.platformAppProvider.vend.getPlatformApp(c.consumerId.get).isEmpty) + code.platformapp.PlatformApps.platformAppProvider.vend.createPlatformApp(c.consumerId.get, s"Test application ${c.name.get}", resourceUser1.userId) lazy val tokenApplication1 = applicationToken(testConsumer) lazy val tokenApplication2 = applicationToken(testConsumer2) - lazy val application1 = Some(consumer, tokenApplication1) - lazy val application2 = Some(consumer2, tokenApplication2) + def application1 = { ensurePlatformApp(testConsumer); Some(consumer, tokenApplication1) } + def application2 = { ensurePlatformApp(testConsumer2); Some(consumer2, tokenApplication2) } lazy val user3 = Some(consumer3, token3) lazy val userGatewayLogin = Some(consumer4, token4) diff --git a/release_notes.md b/release_notes.md index 0eae72a5cb..3c660dea7a 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,33 @@ ### Most recent changes at top of file ``` Date Commit Action +10/11/2026 TBD REMOVED: endpoint auth modes (authMode: UserOnly, ApplicationOnly, UserXORApplication, + UserAndApplication). Who is acting on each request now decides where a Role comes from, + the same way on every endpoint. A person needs the Role as an Entitlement. An + application acting as itself needs it as a Scope on its Consumer. + An application acts as itself only if its Consumer is a Platform App and it calls with + no User or as its own client-credentials User (obp-oidc#client, or keycloak#client for a + Keycloak service account). OPERATORS: mark the applications that act as themselves as + Platform Apps, for example Bank-Node, Sentinel, and the Portal, API Manager and Cohort + for their own tokens. Without the mark their Scopes only work as a limit on people. + An application acting as itself is now let in on its Scope on every endpoint that needs + a Role, not only on the 73 that were UserXORApplication. Inline Role checks in handlers + follow the same rule. Consumer Account Access works on every endpoint of the granted + view. + Who may call an endpoint is now read from its error list only. OBP-20001 or a Role: a + User is required. OBP-20200 without OBP-20001: an identified application is required and + a User is optional. The 22 Berlin Group v1.3 and UK Open Banking v3.1 and v4.0.1 consent + and payment endpoints now list OBP-20200 in place of OBP-20001. Endpoints with a Role + called with no credentials answer 401 OBP-20001 again, not OBP-20200. + require_scopes_for_all_roles and require_scopes_for_listed_roles still make a person + also need the Scope on the Consumer they call through. + Dynamic Entities: auth_mode is removed from the v6.0.0 and v7.0.0 definition endpoints + and is no longer stored. A client that still sends it (or authMode, in the v4.0.0 + format) is not refused: the field is ignored. The data endpoints work like static + endpoints with a Role, so a User is required. The old auth_mode column stays in existing + databases, unused. + v7.0.0 Get My Role: allowed_for_auth_modes is replaced by allowed (boolean). Glossary: + API.Endpoint Auth Modes is replaced by API.Entitlements and Scopes. 06/11/2026 TBD FIXED: the consumer quota. A per-second limit held for only about half of each second: Redis rounds TTL to the nearest second, so a 1-second counter read TTL 0 for its last ~500 ms while still holding its count, and was treated as expired, letting every call diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index cf0e026d2d..a3622ea683 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -1343,41 +1343,41 @@ "version": "v6_0_0", "endpoint": "createBankLevelDynamicEntity", "field": "description", - "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted.", + "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-10-29: auth_mode UserOrApplication renamed to UserXORApplication (Scopes for the application acting as itself, Entitlements for a person); the former name is still accepted. Digest refresh 2026-11-10: auth_mode is removed (endpoint auth modes are gone); the text now says that the entity's Roles decide who may use its data endpoints.", "lift_digest": "2e7c649041362776f7eec66ccbb9f025f3edfead992108d0fd5864af76e93961", - "http4s_digest": "d2a81d825f3e109ec7cb4e6aece220fe2334a924be6a4c57a8294a3bc8bab0f1" + "http4s_digest": "b77a6503d0ca6497361d121f49b8eae398409cfe1ad49b59ce6cdc092fe1dcfd" }, { "version": "v6_0_0", "endpoint": "createSystemDynamicEntity", "field": "description", - "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag.", + "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag. Digest refresh 2026-11-10: auth_mode is removed (endpoint auth modes are gone); the text now says that the entity's Roles decide who may use its data endpoints.", "lift_digest": "81310cdd2be1c2b33d465cabd1604cedee38bfa1ecccae4b016d0f99aa443ab6", - "http4s_digest": "a54cf6b69e5dd2ea2ac99f012f4e2986adeb2f5c49b344c8984b21f4757b0110" + "http4s_digest": "138cb03ffecb8f5ba4e90ed66a2873e23863d31a86bdcddc353621380746561d" }, { "version": "v6_0_0", "endpoint": "updateBankLevelDynamicEntity", "field": "description", - "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag.", + "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag. Digest refresh 2026-11-10: auth_mode is removed (endpoint auth modes are gone); the text now says that the entity's Roles decide who may use its data endpoints.", "lift_digest": "2d18665f9db52506a1b043ad7575b16ac52cedf0d11f036afd4a96b353eb8ddd", - "http4s_digest": "9cbf8c520307ca38d82c69d079504d19c344696220ec459b9ef38475adb55332" + "http4s_digest": "f0fc539d1a6058fe378ce8fd001f7f5f60ffb6c28dbf0ef44f02d22f7db55cea" }, { "version": "v6_0_0", "endpoint": "updateMyDynamicEntity", "field": "description", - "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag.", + "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag. Digest refresh 2026-11-10: auth_mode is removed (endpoint auth modes are gone); the text now says that the entity's Roles decide who may use its data endpoints.", "lift_digest": "97f0c97cc592f89f4dba8a6641ad4b41f6ffa6f4f6e78bf3d3cc34a6a11f76e7", - "http4s_digest": "71c7c1036177280c60d568471033fc9dac7dbdc5ae171241920bd6bc49edf9ea" + "http4s_digest": "8f05614b7cb370ede150f2ef09c424303f23a1ba556c3531e60c74a161fcdfb2" }, { "version": "v6_0_0", "endpoint": "updateSystemDynamicEntity", "field": "description", - "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag.", + "reason": "Digest refresh: row-level access grant endpoint corrected from GET/POST to GET/PUT (matches Method.PUT route in Http4sDynamicEntity.scala), on top of the field-level access control and indexed/index docs already reviewed. Digest refresh 2026-10-02: documents the hide_field_from_public_access field flag. Digest refresh 2026-11-10: auth_mode is removed (endpoint auth modes are gone); the text now says that the entity's Roles decide who may use its data endpoints.", "lift_digest": "e28c2996f3fd54842534fd54d45d7ba49df8b346e65f541353be4fb5311b550c", - "http4s_digest": "03efe3fdb3fb7c924c0c880942931e6fe51dc889d288a52946ee7b84a25f95db" + "http4s_digest": "ce26f4656c8c69376358260477e9ab0bb4051a1c043f83288a10519058b51f1c" }, { "version": "v3_0_0", @@ -2215,17 +2215,17 @@ "version": "v6_0_0", "endpoint": "getOidcClient", "field": "description", - "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry.", + "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry. Digest refresh 2026-11-10: documents the Consumer's grant_types in the response.", "lift_digest": "49ddd5f6ec985292ee48802cd18e479c5a1b46fee368ce0d06a653e07eb65e90", - "http4s_digest": "afb1da33f039efe8cff6de2d718c11e552434fb08f7e41a66d82a26d07be0de6" + "http4s_digest": "2cbc0e150e744d45e4ef13b2629a834bdaab700a0da64b32d157c21f923d366e" }, { "version": "v6_0_0", "endpoint": "verifyOidcClient", "field": "description", - "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry.", + "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry. Digest refresh 2026-11-10: documents the Consumer's grant_types in the response.", "lift_digest": "120f4c6ed1083567d078a7afbb5c2e97d8de69e68e7314166c05e3afcdf50ecd", - "http4s_digest": "670c0479be7d2aa3c8beb18d2e1708c3ec7e67b2a1b625018b07a89c770fe962" + "http4s_digest": "b668308e83b4f0420c549eb9bd69a762b93991dfe1d5da3874019cf9b4050a08" }, { "version": "v5_1_0", diff --git a/todo/todo_remove_endpoint_auth_mode.md b/todo/todo_remove_endpoint_auth_mode.md new file mode 100644 index 0000000000..de3cac0d97 --- /dev/null +++ b/todo/todo_remove_endpoint_auth_mode.md @@ -0,0 +1,185 @@ +# Remove the endpoint auth mode + +Status: built on 2026-11-10, not yet committed. Steps 2, 4, 5, 6 and 7 are done (see "What was built" at the end). +Steps 1 and 3 remain. This replaces an earlier proposal (2026-11-09) to let +installations override each endpoint's auth mode. It follows on from `authmode_user_or_application_design.md`, and +changes one principle from that note: see "The Platform App marker" below. + +## The issue + +Every static endpoint has an `authMode` in its ResourceDoc: UserOnly, ApplicationOnly, UserXORApplication or +UserAndApplication. It mixes two different questions into one setting: + +- **Is a user required?** This is already answered in two other places: by the handler's helper (`withUser` + refuses a call without a user), and by the ResourceDoc's error list and Roles. +- **Where may the Role come from: a person's Entitlement, or a Consumer's Scope?** This follows from who is acting + on each request, which is a fact about the request, not about the endpoint. + +Because the mode repeats what other places say, it drifts from them. An inventory script, since removed, found +on 2026-11-10: + +- 1,349 endpoints with the default UserOnly, which nobody chose for them; +- 13 endpoints that are UserXORApplication although their handler uses a person; +- 21 endpoints open to applications whose handler still insists on a user. + +The mode also cannot express what installations actually want. It is the same for every installation, but one +bank may let its integration app delete Entitlements and another may not. That decision is already made, per +installation, when a Role is granted. + +What the mode does do, in practice, is guard against a muddle in what a Scope means (see below). That guard +belongs where the muddle starts, not on each endpoint. + +## How we got here + +| Date | Who | Change | +|---|---|---| +| 2018-05-04 | Hongwei | Scopes: Roles held by a Consumer instead of a user | +| 2019-05-06 | Marko | `require_scopes_for_all_roles` and `require_scopes_for_listed_roles`: a Scope as a **limit on people** (a person needs the Entitlement, and the app they use needs the Scope) | +| 2022-02-09 | Marko | `allow_roles_or_scopes`, renamed `allow_entitlements_or_scopes`: one global switch letting a Scope stand **instead of** an Entitlement | +| 2026-03-05 | Simon | The global switch replaced by a per-endpoint `authMode`; first used by `verifyUserCredentials` for OBP-OIDC | +| 2026-09-03 | Simon | Dynamic Entities get their own `auth_mode` | +| 2026-10-29 | Simon | UserOrApplication replaced by UserXORApplication | + +## The two meanings of a Scope + +A Scope on a Consumer means one of two things today, and the code cannot tell which: + +| Meaning | What it says | +|---|---| +| A limit on people (since 2019) | People may use Role X through this application. The person still needs the Entitlement. | +| A permission for the application (since 2022) | This application may use Role X itself. | + +The risk is that a Scope granted as a limit on people is read as a permission for the application. Common +practice keeps the two apart. Microsoft Entra ID, for example, has "delegated permissions" and "application +permissions" as separate kinds of grant. + +## The new direction + +**Remove `authMode` from static endpoints.** Each request is then decided by who is acting: + +- **A person acting:** the Role comes from their Entitlement. If the installation uses `require_scopes_for_*`, + the application they use must also hold the Scope. This is the Scope as a limit on people. +- **The application acting as itself:** the Role comes from its Consumer's Scope. This is the Scope as a + permission for the application. + +**The application counts as acting as itself only if both of these hold:** + +1. **It proved its identity as the application.** That means a client-credentials token, or a client + certificate. Identification comes first. A public client (for example a browser app using PKCE), a consumer + key on its own, or a person's DirectLogin token does not prove it. +2. **Its Consumer is a Platform App.** An administrator marks it, with the Role `CanCreatePlatformApp`. + +A Consumer that is not a Platform App can still hold Scopes, but they only ever work as a limit on people. This +settles the two meanings of a Scope: the Platform App marker says which one applies. + +**Endpoints that need a person keep requiring one in their code.** For example, `/my/...` endpoints use +`withUser` and the caller's own identity. No setting on the endpoint is needed, and nothing can drift. + +### The Platform App marker + +The 2026-10-29 note said the marker grants nothing, so rules must not depend on it. This direction changes that +on purpose: + +- Marking a Consumer as a Platform App still grants no Scopes. It lets the application declare the Scopes it + needs (`PUT /obp/v7.0.0/consumers/current/platform-app`), and an administrator then grants them. +- Being a Platform App now also means its Scopes are permissions for the application itself. +- We define a Platform App by that: any application that acts with Scopes by itself is a Platform App. This + includes an installation's own applications, such as the Portal, the API Manager, Sentinel, Bank-Node and + Keycloak. It also includes a third party's backend that needs to act as itself. + +### How it fits the standards + +This is the standard confidential-client pattern under an OBP name. OAuth leaves the access rules inside an API to +the API, so OBP does not go against any standard here. The relevant standards are in the glossary item "OAuth +Standards and OBP". + +### Consumer grant types + +The Consumer grant types built on 2026-11-09 stay as they are. (A person "logs in through a Consumer" when they get +a token with that Consumer's client id or key. They can do this by an OAuth2 login, by DirectLogin, or by renewing such a login.) They are set with +`PUT /management/consumers/CONSUMER_ID/grant-types`. They say whether people can log in through a Consumer, and +whether the application can get a token for itself. The two do not need to be combined. A Platform App whose grant types exclude `client_credentials` +cannot get a token of its own, so it cannot act as itself (decision 1). Whether we keep the grant types at all is +open question 1. + +## Dynamic Entities + +A Dynamic Entity's `auth_mode` has the same problem. But an entity has no handler code of its own, so part of its +setting is real configuration. Its personal (`/my/`) and row-level endpoints already always need a person, +whatever `auth_mode` says. With the rule above, the remaining choice is whether applications may use the entity's +data on their own at all. That can also be expressed by granting, or not granting, the entity's Scopes to a +Platform App. So `auth_mode` on entities can probably go too. This is an open question. + +## Effect, measured on 2026-11-10 + +Removing the mode applies the same rule to every endpoint: + +| Endpoints | Today | Afterwards | +|---|---|---| +| 73 UserXORApplication | A person with the Entitlement, or the application acting as itself with the Scope | No change, except that the application must now be a Platform App | +| 224 UserOnly with Roles, whose handler does not insist on a user | Only a person | Also a Platform App acting as itself, if it holds the Scope | +| 397 UserOnly with Roles, whose handler uses `withUser` | Only a person | Also a Platform App acting as itself with its own user (it gets one through client credentials). The handler treats that user as the caller. | +| 728 UserOnly without Roles | A user is required | The Role check does not apply. Handlers that assume a user must be checked, so a call without one is refused cleanly. | + +The error for a call with no credentials should stay "User not logged in" (OBP-20001). Otherwise 637 assertions in +158 test files, and every client that checks the error, would change. + +## Steps + +1. **Audit Scopes.** List which Scopes each Consumer holds, and why. Start with the Platform Apps and with + installations that use `require_scopes_for_*`. A Scope that a Platform App holds only as a limit on people + would become a permission for it. +2. **Identify the application first.** Make "acting as itself" mean: proved its identity (client-credentials + token or client certificate) and its Consumer is a Platform App. Check how a call with only a consumer key is + treated when `consumer_validation_method_for_consent=CONSUMER_KEY_VALUE`. +3. **Mark the applications that act as themselves today** as Platform Apps: Bank-Node, Sentinel, and the + Portal, the API Manager and Cohort for their own tokens. This is an operator step, so it needs a release note. +4. **Remove `authMode`** from static ResourceDocs and from the access check, keeping OBP-20001 for calls with no + credentials. Fix the handlers that assume a user is present. +5. **Decide on Dynamic Entities' `auth_mode`,** and keep the stored value readable for old definitions. +6. **Add a test** that fails when a handler that needs a person can be reached without one. Done as + `UserOptionalHandlerTest`: a handler whose doc needs only an identified application must not insist on a user. + The auth sweep (`AuthSweepTest`) covers the endpoints that need a user. +7. **Update the documentation:** the glossary items "API.Endpoint Auth Modes", "Platform Apps" and "Scopes", the + Scopes section of `sample.props.template` (it still says `UserOrApplication`), and the release notes. + +## Decisions (2026-11-10) + +1. **What the Platform App marker means:** only that the Consumer's Scopes can be used by the application itself. + It implies nothing about grant types. If a Platform App's grant types exclude `client_credentials`, it cannot + get a token of its own, so it cannot act as itself. No extra rule is needed. +2. **Dynamic Entities:** applications that need an entity's data on their own are set up as Platform Apps. So an + entity's `auth_mode` is not needed. It is removed from the v6.0.0 and v7.0.0 definitions and no longer stored. A + client that still sends it is not refused: the field is ignored. Entities work like static endpoints. +3. **One Consumer or two for applications that do both:** we cannot require two now. But we aim for it in the long + run, as better security. For example, the Portal would have one Consumer for people logging in and one for its + own token. +4. **The name:** we keep "Platform App". It means an accepted application, inside or outside the organisation. This + may mean more Platform Apps to manage on a sandbox, which is acceptable. +5. **Consumer grant types** (built on 2026-11-09): we keep them only to control how tokens can be obtained through + a Consumer. They no longer decide whether a call is the application acting as itself. That change was made on + 2026-11-10, in `APIUtil.isApplicationActingAsItself`. So each question has one rule: Platform App decides whether + the Consumer's Scopes can be used by the application itself, and grant types decide which logins are allowed. + +## Open questions + +1. Decided: see decision 5. +2. Decided: see decision 2. + +## What was built (2026-11-10) + +- `ResourceDoc.authMode` and `EndpointAuthMode` are gone. `handleAccessControlRegardingEntitlementsAndScopes` is the + one access check. It is used by the middleware, the inline Role checks, Dynamic Entities, Files and gRPC. +- Who may call is read from the error list (`ResourceDoc.requiresApplicationButNotUser`). The 22 Berlin Group and UK + consent and payment endpoints list `ApplicationNotIdentified` in place of `AuthenticatedUserIsRequired`. The + parity audit does not cover those standards, so no allowlist entries were needed. +- An application acts as itself only if its Consumer is a Platform App and it calls with no User or as its own + client-credentials User (`obp-oidc#client`, or `keycloak#client` for a Keycloak service account). +- Consumer Account Access no longer needs the endpoint to opt in. +- v7.0.0 Get My Role returns `allowed` in place of `allowed_for_auth_modes`. API Explorer II, OBP-OIDC, Sentinel and + OBP-Frontend's shared role check read it. +- Dynamic Entity `auth_mode` is removed from the v6.0.0 and v7.0.0 JSON, the provider and the mapped column. API + Manager no longer offers it, and the shared entity bootstrap no longer treats it as drift. +- The glossary item "API.Endpoint Auth Modes" is replaced by "API.Entitlements and Scopes". +- `UserOptionalHandlerTest` reads the handler of every doc that needs only an identified application, and fails if + it insists on a user. The inventory script was removed: with no auth modes left, it had nothing to sort by. From 7508b7ee8247bebf23f3fde07fd2aacdace230b8 Mon Sep 17 00:00:00 2001 From: Simon Redfern Date: Thu, 12 Nov 2026 14:14:42 +0100 Subject: [PATCH 4/4] CLA for entities and tweaks to Open API endpoints --- CONTRIBUTING.md | 3 + ...ntity_Contributor_Assignment_Agreement.txt | 122 ++++++++++++++++++ .../OpenAPI31JSONFactory.scala | 21 ++- .../main/scala/code/api/util/Glossary.scala | 69 +++++++--- .../code/api/v1_4_0/JSONFactory1_4_0.scala | 37 +++++- .../code/entitlement/MappedEntitlements.scala | 55 ++++---- .../OpenAPI31FactoryTest.scala | 36 +++++- .../JSONFactory1_4_0TypedBodyTest.scala | 118 +++++++++++++++++ .../code/concurrency/CONCURRENCY_HAZARDS.md | 2 +- 9 files changed, 414 insertions(+), 49 deletions(-) create mode 100644 Harmony_Entity_Contributor_Assignment_Agreement.txt create mode 100644 obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0TypedBodyTest.scala diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9838fd5705..2a0d7595d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,9 @@ If submitting a pull request please read and sign our [CLA](http://github.com/Op In the first instance it is sufficient if you create a text file of the CLA with your name and include that in a git commit description. If you end up making large changes to the source code, we might ask for a paper signed copy of your CLA sent by email to contact@tesobe.com +If you contribute for a company or other organisation, the organisation should sign the [Entity CLA](http://github.com/OpenBankProject/OBP-API/blob/develop/Harmony_Entity_Contributor_Assignment_Agreement.txt) instead. +It covers everyone who contributes on the organisation's behalf. Send a signed copy by email to contact@tesobe.com. + ## Git commit messages Please structure git commit messages in a way as shown below: diff --git a/Harmony_Entity_Contributor_Assignment_Agreement.txt b/Harmony_Entity_Contributor_Assignment_Agreement.txt new file mode 100644 index 0000000000..0b047b3675 --- /dev/null +++ b/Harmony_Entity_Contributor_Assignment_Agreement.txt @@ -0,0 +1,122 @@ +Harmony Entity Contributor Assignment Agreement + +Thank you for your interest in contributing to Open Bank Project ("We" or "Us"). + +This contributor agreement ("Agreement") documents the rights granted by contributors to Us. To make this document effective, please sign it and send it to Us by mail, email, fax, or electronic submission, following the instructions at [SUBMISSION_INSTRUCTIONS below]. This is a legally binding document, so please read it carefully before agreeing to it. The Agreement may cover more than one software project managed by Us. + +1. Definitions + + "You" means any Legal Entity on behalf of whom a Contribution has been received by Us. "Legal Entity" means an entity which is not a natural person. "Affiliates" means other Legal Entities that control, are controlled by, or under common control with that Legal Entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such Legal Entity, whether by contract or otherwise, (ii) ownership of fifty percent (50%) or more of the outstanding shares or securities which vote to elect the management or other persons who direct such Legal Entity or (iii) beneficial ownership of such entity. + + "Contributor" means any employee, officer, contractor or agent of You or Your Affiliates who creates a Contribution. + + "Contribution" means any work of authorship that is Submitted by You to Us, or that You and We agree in writing is a Contribution under this Agreement whether or not it is Submitted, in which You own or assert ownership of the Copyright. If You do not own the Copyright in the entire work of authorship, please follow the instructions in [NONOWNER_INSTRUCTIONS below]. + + "Copyright" means all rights protecting works of authorship owned or controlled by You or Your Affiliates, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence including any extensions by You. + + "Material" means the work of authorship which is made available by Us to third parties. When this Agreement covers more than one software project, the Material means the work of authorship to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. + + "Submit" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Material, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + + "Submission Date" means the date on which You Submit a Contribution to Us. + + "Effective Date" means the date You execute this Agreement or the date You first Submit a Contribution to Us, whichever is earlier. + + "Media" means any portion of a Contribution which is not software. + + +2. Grant of Rights + +2.1 Copyright Assignment + +(a) You assign to Us, by way of present assignment of future rights, all right, title, and interest worldwide in all Copyright covering each Contribution, with effect from the time the Contribution is created; provided that this transfer is conditioned upon compliance with Section 2.3. + +(b) To the extent that any of the rights in Section 2.1(a) cannot be assigned by You to Us, You grant to Us a perpetual, worldwide, exclusive, royalty-free, transferable, irrevocable license under such non-assigned rights, with rights to sublicense through multiple tiers of sublicensees, to practice such non-assigned rights, including, but not limited to, the right to reproduce, modify, display, perform and distribute the Contribution; provided that this license is conditioned upon compliance with Section 2.3. + +(c) To the extent that any of the rights in Section 2.1(a) can neither be assigned nor licensed by You to Us, You irrevocably waive and agree never to assert such rights against Us, any of our successors in interest, or any of our licensees, either direct or indirect; provided that this agreement not to assert is conditioned upon compliance with Section 2.3. + +(d) Upon such transfer of rights to Us, to the maximum extent possible, We immediately grant to You a perpetual, worldwide, non-exclusive, royalty-free, transferable, irrevocable license under such rights covering the Contribution, with rights to sublicense through multiple tiers of sublicensees, to reproduce, modify, display, perform, and distribute the Contribution. The intention of the parties is that this license will be as broad as possible and to provide You with rights as similar as possible to the owner of the rights that You transferred. This license back is limited to the Contribution and does not provide any rights to the Material. + +2.2 Patent License + +For patent claims including, without limitation, method, process, and apparatus claims which You or Your Affiliates own, control or have the right to grant, now or in the future, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable patent license, with the right to sublicense these rights to multiple tiers of sublicensees, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with the Material (and portions of such combination). This license is granted only to the extent that the exercise of the licensed rights infringes such patent claims; and provided that this license is conditioned upon compliance with Section 2.3. + +2.3 Outbound License + +Based on the grant of rights in Sections 2.1 and 2.2, if We include Your Contribution in a Material, We may license the Contribution under any license(s), including copyleft, permissive, commercial, or proprietary licenses and we agree to license the Contribution under an AGPL V3 or later compatible license. + +2.4 Moral Rights. If moral rights apply to the Contribution, to the maximum extent permitted by law, You waive and agree not to assert such moral rights against Us or our successors in interest, or any of our licensees, either direct or indirect. You shall obtain from each Contributor a written waiver of their moral rights in each Contribution, in favour of Us, our successors in interest and our licensees. + +2.5 Our Rights. You acknowledge that We are not obligated to use Your Contribution as part of the Material and may decide to include any Contribution We consider appropriate. + +2.6 Reservation of Rights. Any rights not expressly assigned or licensed under this section are expressly reserved by You. + +2.7 Further Documents. You shall sign any further documents We reasonably request to confirm the rights granted to Us under this Agreement. + +3. Agreement + +You confirm that: + +(a) You have the legal authority to enter into this Agreement. + +(b) You or Your Affiliates own the Copyright and patent claims covering the Contribution which are required to grant the rights under Section 2. + +(c) The grant of rights under Section 2 does not violate any grant of rights which You or Your Affiliates have made to third parties. + +(d) Each Contributor has assigned to You or Your Affiliates, in writing, all Copyright in the Contributions they create, so that You can grant the rights under Section 2. + +(e) You have followed the instructions in [NONOWNER_INSTRUCTIONS], if You do not own the Copyright in the entire work of authorship Submitted. + +4. Disclaimer + +EXCEPT FOR THE EXPRESS WARRANTIES IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US AND BY US TO YOU. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION TO THE MINIMUM PERIOD PERMITTED BY LAW. + +5. Consequential Damage Waiver + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU OR US BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. NOTHING IN THIS AGREEMENT LIMITS OR EXCLUDES LIABILITY FOR DEATH OR PERSONAL INJURY CAUSED BY NEGLIGENCE, FOR FRAUD OR FRAUDULENT MISREPRESENTATION, OR FOR ANY OTHER LIABILITY THAT CANNOT BE LIMITED OR EXCLUDED BY LAW. + +6. Miscellaneous + +6.1 This Agreement, and any dispute or claim (including non-contractual disputes or claims) arising out of or in connection with it, will be governed by and construed in accordance with the law of England and Wales. + +6.2 The courts of England and Wales have exclusive jurisdiction to settle any dispute or claim arising out of or in connection with this Agreement. However, We may instead choose to refer any such dispute to arbitration under the Rules of Arbitration of the International Chamber of Commerce, seated in London, in English, with one arbitrator, by giving written notice to You before or within 30 days after proceedings are started. + +6.3 This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings, except any separate written agreement between You and Us that refers to this Agreement. + +6.4 If You or We assign the rights or obligations received through this Agreement to a third party, as a condition of the assignment, that third party must agree in writing to abide by all the rights and obligations in the Agreement. + +6.5 The failure of either party to require performance by the other party of any provision of this Agreement in one situation shall not affect the right of a party to require such performance at any time in the future. A waiver of performance under a provision in one situation shall not be considered a waiver of the performance of the provision in the future or a waiver of the provision in its entirety. + +6.6 If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and which is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. + +You +________________________ +Entity name: ________________________ +Company number: ________________________ +Name: ________________________ +Title: ________________________ +Address: ________________________ + ________________________ +GitHub organisation: ________________________ +Email: ________________________ + +Us +________________________ +Name: Simon Redfern +Title: CEO, TESOBE GmbH +Address: Osloerstrasse 16/17, Berlin 13359, Germany + +Schedule: individuals who Submit Contributions on Your behalf + +Name GitHub username Email +________________________ ________________________ ________________________ +________________________ ________________________ ________________________ + + +[SUBMISSION_INSTRUCTIONS] - Sign and send a scanned copy by email to contact@tesobe.com. + +[NONOWNER_INSTRUCTIONS] - Please tell us in person and in writing. + + +Based on the Harmony Entity Contributor Assignment Agreement version 1.0 (www.harmonyagreements.org), with changes by TESOBE GmbH. +This work is licensed under a Creative Commons Attribution 3.0 Unported License. \ No newline at end of file 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 6927904f87..74506be152 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 @@ -662,7 +662,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { content = Map( "application/json" -> MediaTypeJson( schema = Some(convertJValueSchemaToSchemaJson(doc.typed_request_body)), - example = Some(doc.typed_request_body) + example = exampleOf(doc.example_request_body) ) ), required = Some(true) @@ -676,7 +676,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { Some(Map( "application/json" -> MediaTypeJson( schema = Some(convertJValueSchemaToSchemaJson(doc.typed_success_response_body)), - example = Some(doc.typed_success_response_body) + example = exampleOf(doc.success_response_body) ) )) } else None @@ -716,6 +716,23 @@ object OpenAPI31JSONFactory extends MdcLoggable { + /** + * The example body of a request or response, as JSON. + * + * An example written as JSON text is held in a JvalueCaseClass. Turned into JSON, the wrapper + * adds a "jvalueToCaseclass" field that is not part of the API, so it is taken off again here. + * The resource docs in OBP's own format do the same. + */ + private def exampleOf(body: Any): Option[JValue] = { + val example = Extraction.decompose(body)(CustomJsonFormats.formats) transform { + case JObject(List(JField("jvalueToCaseclass", inner))) => inner + } + example match { + case JNothing | JNull => None + case other => Some(other) + } + } + /** * Extracts path parameters from OpenAPI path format */ diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 8d81048355..8a23efa503 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -1549,21 +1549,52 @@ object Glossary extends MdcLoggable { title = "Consumer", description = s""" - |The "consumer" of the API, i.e. the web, mobile or serverside "App" that calls on the OBP API on behalf of the end user (or system). - | - |Each Consumer has a consumer key and secret which allows it to enter into secure communication with the API server. + |# Consumer | - |A Consumer is given a Consumer ID (a UUID) which appears in logs and messages to the backend. + |A Consumer is an application that calls OBP: a web, mobile or server application. Every call to OBP is made through a Consumer, whether a person is logged in or not. The Consumer records which application made the call. | - |A Consumer may be pinned to an mTLS certificate i.e. the consumer record in the database is given a field which matches the PEM representation of the certificate. + |This item gives an overview. Each part has its own Glossary item with the details. | - |After pinning, the consumer must present the certificate in all communication with the server. + |## Identifying the application | - |There is a one to one relationship between a Consumer and its certificate. i.e. OBP does not (currently) store the history of certificates bound to a Consumer. If a certificate expires, the third party provider (TPP) must generate a new consumer using a new certificate. In this case, related resources such as rate limits and scopes must be copied from the old consumer to the new consumer. In the future, OBP may store multiple certificates for a consumer, but a certificate will always identify only one consumer record. + |- **Consumer ID.** A UUID that OBP gives the Consumer. It appears in logs, metrics and messages to the backend. Use it whenever you refer to a Consumer. + |- **Key and secret.** The application uses these to get tokens. The key is also called the client id. See ${getGlossaryItemLink("Consumer.consumer_key (Consumer Key)")}. + |- **Certificate.** A Consumer can be tied to an mTLS certificate. It must then present that certificate on every call. A Consumer has one certificate at a time. When a certificate expires, an administrator replaces it with Update Consumer Certificate, and the Consumer keeps its Scopes, rate limits and other settings. + |- **Redirect URL.** Where people are sent back to after they log in through the Consumer. See ${getGlossaryItemLink("Redirect URL")}. | - |A Consumer for an app the installation runs itself (the Portal, the API Manager, Opey, OBP-Sentinel or a bank's own service) that calls OBP with its own application token needs Roles granted to it as Scopes. An administrator marks it as a Platform App, the app declares the Scopes it needs, and the administrator grants them. See ${getGlossaryItemLink("Platform Apps")} + |## How tokens can be obtained | - """) + |A Consumer's **grant types** say how tokens can be obtained through it. People may log in through it by OAuth2 or by DirectLogin, the application may get a token for itself, or both. A Consumer with no grant types set can be used in every way. See ${getGlossaryItemLink("Consumer Grant Types")}. + | + |## What the application may do + | + |A Role can be granted to a Consumer as a **Scope**. Who is acting on a call decides whether the Scope counts: + | + |- When a person calls through the Consumer, their own Entitlements count. The Consumer's Scopes do not give them anything. An installation can also require the Consumer to hold the Scope, as a limit on people. + |- When the application acts as itself, with no person logged in, its Consumer's Scopes count. It can do this only if its Consumer is marked as a **Platform App**. + | + |See ${getGlossaryItemLink("API.Entitlements and Scopes")} and ${getGlossaryItemLink("Platform Apps")}. + | + |## Which accounts the application may use + | + |A Scope lets an application call an endpoint. A Platform App that acts on an account as itself also needs access to a view of that account. An example is initiating payments from the account. See ${getGlossaryItemLink("Consumer Account Access")}. + | + |## Switching a Consumer off + | + |An administrator can disable a Consumer. Every call through it is then refused, by people and by the application itself. It can be enabled again later. + | + |## Endpoints + | + |- [Get Consumers](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getConsumers): `GET /obp/v7.0.0/management/consumers` + |- [Get Consumer](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getConsumer): `GET /obp/v7.0.0/management/consumers/CONSUMER_ID` + |- [Create a Consumer](${apiExplorerUrl}/resource-docs/OBPv5.1.0?operationid=OBPv5.1.0-createConsumer): `POST /obp/v5.1.0/management/consumers` + |- [Enable or Disable Consumers](${apiExplorerUrl}/resource-docs/OBPv3.1.0?operationid=OBPv3.1.0-enableDisableConsumers): `PUT /obp/v3.1.0/management/consumers/CONSUMER_ID` + |- [Update Consumer Certificate](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateConsumerCertificate): `PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/consumer/certificate` + |- [Update Consumer RedirectURL](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateConsumerRedirectURL): `PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/consumer/redirect_url` + |- [Get Current Consumer](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-getCurrentConsumer): `GET /obp/v6.0.0/consumers/current`, for an application to read its own Consumer. + | + |The endpoints for grant types, Scopes, Platform Apps and account access are listed in their own items. + |""".stripMargin) glossaryItems += GlossaryItem( title = "Consumer.consumer_key (Consumer Key)", @@ -7429,13 +7460,13 @@ object Glossary extends MdcLoggable { s""" |# Platform Apps | - |A **Platform App** is an application an installation runs as part of its own OBP deployment, such as the Portal, the API Manager, Opey or one of the bank's own services. Like any application it calls OBP as a Consumer, and some of its calls are made with its own application token (OAuth2 client credentials) rather than for a logged-in User: reading published pages for anonymous visitors, or creating the Dynamic Entities it depends on at startup. Those calls need Roles granted to its Consumer as Scopes. + |A **Platform App** is an application that an installation runs as part of its own OBP deployment. Examples are the Portal, the API Manager, Opey and the bank's own services. Like any application, it calls OBP as a Consumer. Some of its calls are made with its own application token (OAuth2 client credentials), not for a logged-in User. For example, it reads published pages for anonymous visitors, or creates the Dynamic Entities it depends on at startup. Those calls need Roles granted to its Consumer as Scopes. | |## How it works | |1. An administrator marks the app's Consumer as a Platform App (Role CanCreatePlatformApp), giving it the name administrators know it by. |2. The app declares, as itself, the Scopes it needs and what each is needed for. It does this whenever it starts or checks itself, so the list follows the version that is running. An app whose Consumer has not been marked cannot declare anything, so an arbitrary Consumer cannot ask to be granted Scopes this way. - |3. OBP compares each declaration with the Scopes the Consumer holds. An administrator with CanGetPlatformApps sees, for every Platform App, which Scopes are held and which are missing, and grants the missing ones (CanCreateScopeAtAnyBank, or CanCreateScopeAtOneBank at the Scope's bank id). + |3. OBP compares each declaration with the Scopes the Consumer holds. An administrator with CanGetPlatformApps sees which Scopes each Platform App holds and which are missing. They grant the missing ones with CanCreateScopeAtAnyBank, or CanCreateScopeAtOneBank at the Scope's bank id. | |A Scope declared as optional is one the app can manage without, for example because another Platform App does the same work. It is shown, but does not count as missing. | @@ -7450,7 +7481,9 @@ object Glossary extends MdcLoggable { |- [Get Current Consumer Scopes](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getCurrentConsumerScopes): `GET /obp/v7.0.0/consumers/current/scopes`, for an app to read the Scopes its Consumer holds. |- [Create Scope for a Consumer](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-addScope): `POST /obp/v7.0.0/consumers/CONSUMER_ID/scopes`, to grant a missing Scope. | - |A Scope says which endpoints an app may call. An app that also acts on an account through a view, such as initiating payments from it, needs access to that view of the account as well: see ${getGlossaryItemLink("Consumer Account Access")}. + |To act as itself, the app needs a token of its own. If its Consumer has grant types set, they must include `client_credentials`. See ${getGlossaryItemLink("Consumer Grant Types")}. + | + |A Scope says which endpoints an app may call. Some apps also act on an account through a view, for example to initiate payments from it. They also need access to that view of the account. See ${getGlossaryItemLink("Consumer Account Access")}. |""".stripMargin) glossaryItems += GlossaryItem( @@ -7513,21 +7546,21 @@ object Glossary extends MdcLoggable { s""" |# Consumer Account Access | - |OBP has two layers of permission. **Roles** say which endpoints may be called; they are granted to a User as Entitlements and to a Consumer as Scopes. **Account access** says which accounts may be used, and through which view; it is granted to a User, and with Consumer Account Access also to a Consumer. + |OBP has two layers of permission. **Roles** say which endpoints may be called. They are granted to a User as Entitlements and to a Consumer as Scopes. **Account access** says which accounts may be used, and through which view. It is granted to a User, and with Consumer Account Access also to a Consumer. | - |It is for an application that acts on an account as itself, with its own application token (OAuth2 client credentials), when no person is involved: for example a bank's service that initiates payments from the bank's own account for customers whose payments it relays. Without it, such a service has to log in as a stand-in User who holds the account access. + |It is for an application that acts on an account as itself, with its own application token (OAuth2 client credentials), when no person is involved. An example is a bank's service that relays customers' payments and initiates them from the bank's own account. Without it, such a service has to log in as a stand-in User who holds the account access. | |## How it works | - |1. Someone who may grant views of the account (their view lists the target view in `can_grant_access_to_views`, or has `can_grant_access_to_custom_views` for a custom view) gives the Consumer access to a view. The grant records who gave it. - |2. The application calls an endpoint of that view with its client-credentials token. Its User is then the Consumer's own (provider `obp-oidc#client`, named after the Consumer's key), and OBP finds the Consumer's grant. + |1. Someone who may grant views of the account gives the Consumer access to a view. Their own view must list the target view in `can_grant_access_to_views`, or have `can_grant_access_to_custom_views` for a custom view. The grant records who gave it. + |2. The application calls an endpoint of that view with its client-credentials token. Its User is then the Consumer's own: provider `obp-oidc#client` (or `keycloak#client` for a Keycloak service account), named after the client id. OBP then finds the Consumer's grant. |3. The Consumer must be a Platform App. The grant then works like a person's access to the view, on every endpoint that uses the view. | - |A person who logs in through the same Consumer does not get the Consumer's account access, and a caller identified by the Consumer Key alone does not either: only the client-credentials token proves the application is calling as itself. + |A person who logs in through the same Consumer does not get the Consumer's account access. Nor does a caller identified only by the Consumer Key. Only the client-credentials token proves that the application is calling as itself. | |## Which view | - |The owner view and public views can't be granted to a Consumer. Holding an account is for people. Give the Consumer a view with only the permissions it needs, for example a custom view with `can_add_transaction_request_to_any_account` for initiating payments: create it with Create Custom View (Management), `POST /obp/v6.0.0/management/banks/BANK_ID/accounts/ACCOUNT_ID/views` (Role CanCreateCustomView), and grant it through the ManageCustomViews system view, which has `can_grant_access_to_custom_views` and which the account holder can give through owner. The Transaction Requests the Consumer creates record it. + |The owner view and public views can't be granted to a Consumer. Holding an account is for people. Give the Consumer a view with only the permissions it needs. For initiating payments, that is a custom view with `can_add_transaction_request_to_any_account`. Create it with Create Custom View (Management), `POST /obp/v6.0.0/management/banks/BANK_ID/accounts/ACCOUNT_ID/views` (Role CanCreateCustomView). Grant it through the ManageCustomViews system view, which has `can_grant_access_to_custom_views`. The account holder can give ManageCustomViews through owner. The Transaction Requests the Consumer creates record it. | |## Endpoints | diff --git a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala index 7d43d06a94..66fd7fafb4 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala @@ -840,6 +840,9 @@ object JSONFactory1_4_0 extends MdcLoggable{ val extractedEntity = entity match { case Full(v) => v case Some(v) => v + // An example body written as JSON text is held in a JvalueCaseClass. Its one field, + // jvalueToCaseclass, is not part of the API, so the schema describes the JSON inside it. + case JvalueCaseClass(v) => v case v => v } @@ -904,6 +907,12 @@ object JSONFactory1_4_0 extends MdcLoggable{ if(value != None) } yield { value match { + // An empty example value - null, or an empty JSON object such as "FirstAvailableDateTime": {} + // in the UK Open Banking examples - says nothing about the field's type. So the field's + // schema says nothing either, and accepts any value. Saying "null" or "an object with no + // properties" would reject every real value. + case null | JNull | JNothing | JObject(Nil) => "\"" + key + """": {}""" + //Date -- should be the first case i: Date => "\"" + key + """": {"type": "string","format": "date-time"}""" case Some(i: Date) => "\"" + key + """": {"type": "string","format": "date-time"}""" @@ -993,7 +1002,6 @@ object JSONFactory1_4_0 extends MdcLoggable{ case Some(List(f,_*)) => "\"" + key + """":""" +translateEntity(f,true) //Single object case Some(f) => "\"" + key + """":""" +translateEntity(f,false) - case null => "\"" + key + """":{"type":"null"}""" case f => "\"" + key + """":""" +translateEntity(f,f.getClass().isArray()) // TODO resolve the warning patterns after a variable pattern cannot match (SLS 8.1.1) // case _ => "unknown" @@ -1003,14 +1011,35 @@ object JSONFactory1_4_0 extends MdcLoggable{ // add comment and filter unknow // fields --> "id" : {"type":"integer", "format":"int32"} ,"name" : {"type":"string"} ,"bank": {"$ref":"#/definitions/Bank"} ,"banks": {"type": "array", "items":{"$ref": "#/definitions/Bank"}} val fields: String = properties filter (_.contains("unknown") == false) mkString (",") - //val definition = "\"" + entity.getClass.getSimpleName + "\":{" + requiredFieldsPart + """"properties": {""" + fields + """}}""" + + // Which fields a body must have. A field of a Scala class is required unless its type is an + // Option (or a Box), because those are the fields left out of the JSON when they have no value. + // A field whose example value is null is not required: the example gives no type to check. + // An example written as JSON text gives no types at all, so none of its fields is required. + // Its example may show optional fields, as the UK Open Banking examples do. + val requiredKeys: List[String] = extractedEntity match { + case _: JValue => Nil + case _ => mapOfFields.toList.collect { + case (name, value) if isRequiredExampleValue(value) => convertParamName(name) + }.sorted + } + val requiredPart = + if (requiredKeys.isEmpty) "" + else requiredKeys.map(k => "\"" + k + "\"").mkString(""""required": [""", ",", "],") + val definition = if (isArray) - """{ "type": "array", "items" : {"type": "object","properties": {""" + fields + """}}}""" + """{ "type": "array", "items" : {"type": "object",""" + requiredPart + """"properties": {""" + fields + """}}}""" else - """{ "type": "object", "properties" : {""" + fields + """}}""" + """{ "type": "object", """ + requiredPart + """"properties" : {""" + fields + """}}""" definition } + private def isRequiredExampleValue(value: Any): Boolean = value match { + case null | JNull | JNothing => false + case _: Option[_] | _: net.liftweb.common.Box[_] => false + case _ => true + } + def createTypedBody(exampleRequestBody: Any): JValue = { def res = translateEntity(exampleRequestBody,false) exampleRequestBody match { diff --git a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala index adcbebc42e..fc66d63801 100644 --- a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala +++ b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala @@ -236,29 +236,40 @@ object MappedEntitlementsProvider extends EntitlementProvider with MdcLoggable { if (targetUserId != userId) logger.warn(s"addEntitlement: role '$roleName' (bankId '$bankId', createdByProcess '$createdByProcess') requested for consent user $userId is granted to its on-behalf-of user $targetUserId") - def addEntitlementToUser(): Box[MappedEntitlement] = { - val entitlement = MappedEntitlement.create - .mBankId(bankId) - .mUserId(targetUserId) - .mRoleName(roleName) - .mCreatedByProcess(createdByProcess) - grantedByUserId.foreach(g => entitlement.mGrantedByUserId(g)) - groupId.foreach(gid => entitlement.mGroupId(gid)) - tryo(entitlement.saveMe()) match { - case Full(saved) => - NotificationUtil.sendEmailRegardingAssignedRole(targetUserId, saved) - Full(saved) - case Failure(_, _, _) => - // UniqueIndex(mBankId, mUserId, mRoleName) violated by concurrent grant — return the committed row - MappedEntitlement.find( - By(MappedEntitlement.mBankId, bankId), - By(MappedEntitlement.mUserId, targetUserId), - By(MappedEntitlement.mRoleName, roleName) - ) - case other => other - } + // If the user already holds this Role at this bank id, return that grant. Look first rather + // than insert and catch the error: on PostgreSQL a failed INSERT aborts the whole request + // transaction, so nothing after it (not even a SELECT) can run. Creating a dynamic entity + // whose Record Roles the creator still held from an earlier grant failed that way with + // "current transaction is aborted" (2026-11-11). + val existing = MappedEntitlement.find( + By(MappedEntitlement.mBankId, bankId), + By(MappedEntitlement.mUserId, targetUserId), + By(MappedEntitlement.mRoleName, roleName) + ) + if (existing.isDefined) { + logger.info(s"addEntitlement: user $targetUserId already holds role '$roleName' at bankId '$bankId', so the existing grant is kept") + return existing + } + + val entitlement = MappedEntitlement.create + .mBankId(bankId) + .mUserId(targetUserId) + .mRoleName(roleName) + .mCreatedByProcess(createdByProcess) + grantedByUserId.foreach(g => entitlement.mGrantedByUserId(g)) + groupId.foreach(gid => entitlement.mGroupId(gid)) + tryo(entitlement.saveMe()) match { + case Full(saved) => + NotificationUtil.sendEmailRegardingAssignedRole(targetUserId, saved) + Full(saved) + case f: Failure => + // Most likely the same grant was made at the same moment by another request + // (UniqueIndex(mBankId, mUserId, mRoleName)). Log it and return the failure: on + // PostgreSQL this request's transaction is now aborted, so it can't look the row up. + logger.error(s"addEntitlement: could not grant role '$roleName' at bankId '$bankId' to user $targetUserId: ${f.messageChain}", f.exception.openOr(null)) + f + case other => other } - addEntitlementToUser() } } diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala index 328af570eb..c44fc4c24d 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala @@ -27,6 +27,8 @@ TESOBE (http://www.tesobe.com/) package code.api.ResourceDocs1_4_0 +import code.api.berlin.group.v1_3.JvalueCaseClass +import code.api.util.APIUtil.EmptyBody import code.api.v1_4_0.JSONFactory1_4_0 import code.setup.ServerSetup import org.json4s.JsonAST.{JNothing, JValue} @@ -40,7 +42,7 @@ import org.json4s.native.JsonMethods.parse */ class OpenAPI31FactoryTest extends ServerSetup { - private def doc(operationId: String, typedBody: JValue): JSONFactory1_4_0.ResourceDocJson = + private def doc(operationId: String, typedBody: JValue, successBody: Any = null): JSONFactory1_4_0.ResourceDocJson = JSONFactory1_4_0.ResourceDocJson( operation_id = operationId, implemented_by = JSONFactory1_4_0.ImplementedByJson("4.0.0", operationId), @@ -50,7 +52,7 @@ class OpenAPI31FactoryTest extends ServerSetup { description = "Test desc", description_markdown = "Test desc", example_request_body = null, - success_response_body = null, + success_response_body = successBody, error_response_bodies = List("OBP-10000"), tags = List("Test"), typed_request_body = JNothing, @@ -111,6 +113,34 @@ class OpenAPI31FactoryTest extends ServerSetup { } } + private def responseExample(successBody: Any): JValue = { + val openApi = OpenAPI31JSONFactory.createOpenAPI31Json( + List(doc("testOp", JSONFactory1_4_0.createTypedBody(successBody), successBody)), "v4.0.0", "http://localhost:8080") + val rendered = parse(org.json4s.native.Serialization.write(openApi)) + (rendered \ "paths" \ "/test" \ "get" \ "responses" \ "200" \ "content" \ "application/json" \ "example") + } + + feature("OpenAPI 3.1 examples") { + scenario("the example is the example body, not its schema") { + val example = responseExample(OpenApiExampleBody("gh.29.uk", Some("A bank"))) + + (example \ "bank_id") should equal(org.json4s.JString("gh.29.uk")) + (example \ "full_name") should equal(org.json4s.JString("A bank")) + (example \ "type") should equal(JNothing) + } + + scenario("an example written as JSON text is shown without its jvalueToCaseclass wrapper") { + val example = responseExample(JvalueCaseClass(parse("""{"Data": {"AccountId": "22289"}}"""))) + + (example \ "jvalueToCaseclass") should equal(JNothing) + (example \ "Data" \ "AccountId") should equal(org.json4s.JString("22289")) + } + + scenario("an endpoint with no example body has no example") { + responseExample(EmptyBody) should equal(JNothing) + } + } + feature("OpenAPI 3.1 auth descriptions and URLs") { scenario("consumerRegistrationUrl falls back to hostname/consumer-registration when no portal URL is set") { OpenAPI31JSONFactory.consumerRegistrationUrl("https://api.example.com") should equal("https://api.example.com/consumer-registration") @@ -189,3 +219,5 @@ class OpenAPI31FactoryTest extends ServerSetup { } } } + +case class OpenApiExampleBody(bank_id: String, full_name: Option[String]) diff --git a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0TypedBodyTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0TypedBodyTest.scala new file mode 100644 index 0000000000..470da40d42 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0TypedBodyTest.scala @@ -0,0 +1,118 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v1_4_0 + +import code.api.berlin.group.v1_3.JvalueCaseClass +import code.api.util.CustomJsonFormats +import org.json4s._ +import com.openbankproject.commons.util.JsonAliases._ +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} + +/** + * Covers the JSON Schema made from an endpoint's example body (createTypedBody). + * + * Some example bodies, such as the UK Open Banking ones, are written as JSON text and held in a + * JvalueCaseClass. The schema made from such a body must describe the JSON itself. It must not + * describe the JvalueCaseClass, whose one field, jvalueToCaseclass, is not part of the API. + */ +class JSONFactory1_4_0TypedBodyTest extends FeatureSpec + with GivenWhenThen + with Matchers + with CustomJsonFormats { + + private val body = JvalueCaseClass(parse( + """{"Data": {"AccountId": "22289", "Amount": 10.5}, "Links": {"Self": "https://example.com"}}""" + )) + + feature("The schema of an example body written as JSON text") { + + scenario("The schema describes the JSON, not the wrapper") { + When("a schema is made from the wrapped body") + val schema = JSONFactory1_4_0.createTypedBody(body) + + Then("the top-level properties are the body's own fields") + (schema \ "properties" \ "jvalueToCaseclass") shouldBe JNothing + (schema \ "properties" \ "Data" \ "properties" \ "AccountId" \ "type").extract[String] shouldBe "string" + (schema \ "properties" \ "Data" \ "properties" \ "Amount" \ "type").extract[String] shouldBe "number" + (schema \ "properties" \ "Links" \ "properties" \ "Self" \ "type").extract[String] shouldBe "string" + } + + scenario("A wrapped body inside another body is described by its JSON too") { + When("a schema is made from a case class holding a wrapped body") + val schema = parse(JSONFactory1_4_0.translateEntity(JvalueCaseClassHolder(body), false)) + + Then("the field's schema describes the JSON inside the wrapper") + (schema \ "properties" \ "inner" \ "properties" \ "jvalueToCaseclass") shouldBe JNothing + (schema \ "properties" \ "inner" \ "properties" \ "Data" \ "type").extract[String] shouldBe "object" + } + } + + feature("Empty example values") { + + scenario("An empty object or a null in the example puts no limit on the field") { + When("a schema is made from an example with an empty object and a null") + val schema = JSONFactory1_4_0.createTypedBody(JvalueCaseClass(parse( + """{"Meta": {"FirstAvailableDateTime": {}, "LastAvailableDateTime": null, "TotalPages": 1}}""" + ))) + + Then("those fields have an empty schema, which accepts any value") + (schema \ "properties" \ "Meta" \ "properties" \ "FirstAvailableDateTime") shouldBe JObject(Nil) + (schema \ "properties" \ "Meta" \ "properties" \ "LastAvailableDateTime") shouldBe JObject(Nil) + (schema \ "properties" \ "Meta" \ "properties" \ "TotalPages" \ "type").extract[String] shouldBe "integer" + } + } + + feature("Required fields") { + + scenario("A field of a Scala class is required unless it is an Option") { + When("a schema is made from a case class with plain and optional fields") + val schema = JSONFactory1_4_0.createTypedBody( + TypedBodyExample("gh.29.uk", Some("A bank"), None, List(TypedBodyItem("1", None)))) + + Then("the plain fields are required and the optional ones are not") + (schema \ "required").extract[List[String]] shouldBe List("bank_id", "items") + (schema \ "properties" \ "full_name" \ "type").extract[String] shouldBe "string" + + And("the same rule applies to the items of a list") + (schema \ "properties" \ "items" \ "items" \ "required").extract[List[String]] shouldBe List("id") + } + + scenario("No field of an example written as JSON text is required") { + When("a schema is made from a wrapped body") + val schema = JSONFactory1_4_0.createTypedBody(body) + + Then("it lists no required fields, because the JSON gives no types") + (schema \ "required") shouldBe JNothing + (schema \ "properties" \ "Data" \ "required") shouldBe JNothing + } + } +} + +case class JvalueCaseClassHolder(inner: JvalueCaseClass) +case class TypedBodyItem(id: String, note: Option[String]) +case class TypedBodyExample(bank_id: String, full_name: Option[String], logo: Option[String], items: List[TypedBodyItem]) diff --git a/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md b/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md index dba94336bb..616d93e210 100644 --- a/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md +++ b/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md @@ -105,7 +105,7 @@ following in mind: | ID | Result | Description | Hazard Shape | Source Location | |---|---|---|---|---| -| **C** | 🟢 PASSED | 8 concurrent entitlement grants: exactly 1 row created (UniqueIndex + tryo + re-fetch) | check-then-insert | `MappedEntitlementsProvider.addEntitlement` | +| **C** | 🟢 PASSED | 8 concurrent entitlement grants: exactly 1 row created (look first, then UniqueIndex + tryo; a losing request gets a logged Failure, because on PostgreSQL its aborted transaction can't re-fetch the row) | check-then-insert | `MappedEntitlementsProvider.addEntitlement` | | **D** | 🟢 PASSED | 8 concurrent `getOrCreateAccountHolder` calls: exactly 1 row created (UniqueIndex + tryo + re-fetch) | check-then-insert | `MapperAccountHolders.getOrCreateAccountHolder` | | **F** | 🟢 PASSED | 8 concurrent `getOrCreateMetadata` calls: no exceptions, exactly 1 row (tryo + re-fetch on constraint violation) | unique-constraint-unhandled | `MappedCounterpartyMetadata.getOrCreateMetadata` | | **I** | 🟢 PASSED | 2 concurrent first-time OAuth logins: both succeed (Try + re-fetch by provider/providerId) | unique-constraint-unhandled | `LiftUsers.getOrCreateUserByProviderId` |