From eab6d8b47e780e4fac8ac4aace506b7c87d158f5 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:09:26 +0200 Subject: [PATCH 01/40] test: pin signing basket conformance, ownership and transition expectations Extend SigningBasketServiceSBSApiTest. Each assertion cites the line of the NextGenPSD2 1.3.16 OpenAPI file or the Implementation Guidelines section it rests on, and checks the database as well as the HTTP response. Covered: upper-case transactionStatus, {href} links, Location and ASPSP-SCA-Approach on create, empty and duplicate id lists, unsupported authorisation body variants, transitions after delete and after authorisation, unknown baskets and authorisations, ownership by TPP, quarantine of baskets created before ownership was recorded, member admission, replay of a finalised challenge against another basket, and a delete racing the final answer. These scenarios fail on the current implementation by design and are turned green by the commits that follow. Two scenarios (consent activation, payment booking) are ignored; they record the target of the execution phase. --- .../v1_3/SigningBasketServiceSBSApiTest.scala | 611 +++++++++++++++--- 1 file changed, 522 insertions(+), 89 deletions(-) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 3d8afa8922..635982bcc8 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -27,23 +27,36 @@ TESOBE (http://www.tesobe.com/) package code.api.berlin.group.v1_3 +import org.json4s._ import code.api.Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID import code.api.berlin.group.ConstantsBG -import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{AuthorisationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, PostSigningBasketJsonV13, ScaStatusJsonV13, SigningBasketGetResponseJson, SigningBasketResponseJson, StartPaymentAuthorisationJson} +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{AuthorisationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, PostSigningBasketJsonV13, SigningBasketGetResponseJson, SigningBasketResponseJson} import code.api.berlin.group.v1_3.model.TransactionStatus import code.api.berlin.group.v1_3.{Http4sBGv13SigningBaskets => APIMethods_SigningBasketsApi} import code.api.util.APIUtil.OAuth._ import code.api.util.ErrorMessages._ -import code.model.dataAccess.BankAccountRouting -import code.setup.{APIResponse, DefaultUsers} +import code.model.TokenType +import code.model.dataAccess.{BankAccountRouting, MappedBankAccount} +import code.setup.APIResponse +import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketPayment, SigningBasketX} +import code.token.Tokens +import code.transactionChallenge.Challenges +import code.transactionrequests.MappedTransactionRequest import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ViewId import com.openbankproject.commons.model.enums.{AccountRoutingScheme, PaymentServiceTypes, StrongCustomerAuthenticationStatus, TransactionRequestTypes} import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan +import org.json4s.native.Serialization.write import org.scalatest.Tag -class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with DefaultUsers { +import java.util.UUID +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { object SBS extends Tag("Signing Baskets Service (SBS)") object createSigningBasket extends Tag(nameOf(APIMethods_SigningBasketsApi.createSigningBasket)) object getSigningBasket extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasket)) @@ -54,27 +67,142 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def object getSigningBasketAuthorisation extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasketAuthorisation)) object updateSigningBasketPsuData extends Tag(nameOf(APIMethods_SigningBasketsApi.updateSigningBasketPsuData)) - // Helper: create a real SEPA payment via BG PIS API and return its paymentId - private def createRealPaymentId(): String = { - val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) - val ibanFrom = accountsRoutingIban.head - val ibanTo = accountsRoutingIban.last + // ───────────────────────────── fixtures ───────────────────────────── + + // Spec references below are lines of psd2-api_v1.3.16-2025-11-27.openapi.yaml ("L1234") and sections + // of the Implementation Guidelines 1.3.16 ("IG §x"). Where the standard leaves a choice to the ASPSP the + // scenario says so and states the choice made. + + /** The tppMessage codes the standard allows for each status of a signing basket call (L11514-11749: MessageCode400_SBS L11514, 401 L11597, 403 L11648, 404 L11680, 409 L11744). */ + private val allowedTppCodes: Map[Int, Set[String]] = Map( + 400 -> Set("FORMAT_ERROR", "PARAMETER_NOT_CONSISTENT", "PARAMETER_NOT_SUPPORTED", "SERVICE_INVALID", "RESOURCE_UNKNOWN", + "RESOURCE_EXPIRED", "RESOURCE_BLOCKED", "TIMESTAMP_INVALID", "PERIOD_INVALID", "SCA_METHOD_UNKNOWN", "SCA_INVALID", + "CONSENT_UNKNOWN", "REFERENCE_MIX_INVALID"), + 401 -> Set("CERTIFICATE_INVALID", "ROLE_INVALID", "CERTIFICATE_EXPIRED", "CERTIFICATE_BLOCKED", "CERTIFICATE_REVOKE", + "CERTIFICATE_MISSING", "SIGNATURE_INVALID", "SIGNATURE_MISSING", "CORPORATE_ID_INVALID", "PSU_CREDENTIALS_INVALID", + "CONSENT_INVALID", "CONSENT_EXPIRED", "TOKEN_UNKNOWN", "TOKEN_INVALID", "TOKEN_EXPIRED"), + 403 -> Set("CONSENT_UNKNOWN", "SERVICE_BLOCKED", "RESOURCE_UNKNOWN", "RESOURCE_EXPIRED"), + 404 -> Set("RESOURCE_UNKNOWN"), + 409 -> Set("REFERENCE_STATUS_INVALID", "STATUS_INVALID") + ) + + private def ibanAccounts = BankAccountRouting + .findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + + private def balanceOf(routing: BankAccountRouting) = MappedBankAccount.find( + By(MappedBankAccount.bank, routing.bankId.value), + By(MappedBankAccount.theAccountId, routing.accountId.value)) + .map(_.balance).openOrThrowException("Can not be empty here") + + private def basketsUrl = V1_3_BG / "signing-baskets" + private def basketUrl(basketId: String) = V1_3_BG / "signing-baskets" / basketId + private def authorisationsUrl(basketId: String) = V1_3_BG / "signing-baskets" / basketId / "authorisations" + private def authorisationUrl(basketId: String, authorisationId: String) = + V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId + + /** + * Lodges a SEPA payment as user1 and returns its id. The default amount is over the challenge + * threshold, so the payment sits at RCVD awaiting SCA, which is the only state a basket may take + * a payment in. A payment of 10 is booked on creation (ACCP) and can no longer be authorised by + * anything. + */ + private def lodgePayment(amount: String = "2001"): String = { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last Views.views.vend.systemView(ViewId(SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)).foreach(view => Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, resourceUser1) ) val initiatePaymentJson = s"""{ | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, - | "instructedAmount": { "currency": "EUR", "amount": "10" }, + | "instructedAmount": { "currency": "EUR", "amount": "$amount" }, | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, | "creditorName": "TestCreditor" |}""".stripMargin val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1) val response: APIResponse = makePostRequest(requestPost, initiatePaymentJson) response.code should equal(201) - val payment = response.body.extract[InitiatePaymentResponseJson] - payment.transactionStatus should be(TransactionStatus.ACCP.code) - payment.paymentId + response.body.extract[InitiatePaymentResponseJson].paymentId + } + + private def createRealPaymentId(): String = lodgePayment() + + /** The stored status of a payment that awaits SCA, and of one that was booked on creation. */ + private val awaitingSca = "RCVD" + private val bookedOnCreation = "ACCP" + + private def idList(ids: List[String]): String = ids.map(id => s""""$id"""").mkString("[", ",", "]") + + private def postBasket(body: String, as: Option[(Consumer, Token)] = user1): APIResponse = + makePostRequest(basketsUrl.POST <@ (as), body) + + private def createBasket(paymentIds: List[String], as: Option[(Consumer, Token)] = user1): String = { + val response = postBasket(s"""{"paymentIds":${idList(paymentIds)}}""", as) + withClue(s"creating a basket of $paymentIds: ") { response.code should equal(201) } + response.body.extract[SigningBasketResponseJson].basketId + } + + private def startAuthorisation(basketId: String, as: Option[(Consumer, Token)] = user1, body: String = "{}"): APIResponse = + makePostRequest(authorisationsUrl(basketId).POST <@ (as), body) + + private def answerAuthorisation(basketId: String, authorisationId: String, as: Option[(Consumer, Token)] = user1, + body: String = """{"scaAuthenticationData":"123"}"""): APIResponse = + makePutRequest(authorisationUrl(basketId, authorisationId).PUT <@ (as), body) + + /** Everything a basket needs for its SCA to be answered: a basket of real payments, and an authorisation on it. */ + private case class StartedBasket(basketId: String, paymentIds: List[String], authorisationId: String) + + private def startedBasket(paymentCount: Int = 1): StartedBasket = { + enableBasketAuthorisation() + val paymentIds = List.fill(paymentCount)(lodgePayment()) + val basketId = createBasket(paymentIds) + val started = startAuthorisation(basketId) + started.code should equal(201) + StartedBasket(basketId, paymentIds, (started.body \ "authorisationId").extract[String]) + } + + /** Tests that answer an SCA need a challenge whose answer is known, and an instance that lets baskets be authorised. */ + private def enableBasketAuthorisation(): Unit = + setPropsValues("suggested_default_sca_method" -> "DUMMY", "signing_basket_authorisation_enabled" -> "true") + + // What the database says, as opposed to what an HTTP response claims. + private def storedBasketStatus(basketId: String): Option[String] = + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId).toOption.map(_.basket.status) + + private def storedPaymentStatus(paymentId: String): String = + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, paymentId)) + .map(_.mStatus.get).openOrThrowException(s"payment $paymentId must exist") + + private def storedChallengeCount(basketId: String): Int = + Challenges.ChallengeProvider.vend.getChallengesByBasketId(basketId).map(_.size).openOrThrowException("challenges must be readable") + + private def storedBasketCount(): Long = MappedSigningBasket.count() + + private def tppCode(response: APIResponse): String = response.body.extract[ErrorMessagesBG].tppMessages.head.code + + /** The status is as expected, the tppMessage code is the expected one, and that code is one the standard allows for that status. */ + private def expectRefusal(response: APIResponse, status: Int, code: String, what: String): Unit = + withClue(s"$what: ") { + response.code should equal(status) + tppCode(response) should equal(code) + allowedTppCodes(status) should contain(code) + } + + // resourceUser1's own token, issued under a second consumer: same person, different TPP. + private lazy val samePsuUnderSecondConsumer = { + val token = Tokens.tokens.vend.createToken( + TokenType.Access, + Some(testConsumer2.id.get), + Some(resourceUser1.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new java.util.Date(System.currentTimeMillis())), + None + ).openOrThrowException("test token creation failed") + Some(consumer2, Token(token.key.get, token.secret.get)) } feature(s"test the BG v1.3 - ${createSigningBasket.name}") { @@ -133,7 +261,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def response.code should equal(201) val createdBasket = response.body.extract[SigningBasketResponseJson] createdBasket.basketId should not be empty - createdBasket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + createdBasket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString) createdBasket._links.self.href should not be empty createdBasket._links.status.href should not be empty createdBasket._links.startAuthorisation.href should not be empty @@ -173,19 +301,19 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def Then("We should get a 200") responseGet.code should be(200) val basket = responseGet.body.extract[SigningBasketGetResponseJson] - basket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + basket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString) basket.payments.isDefined should be(true) basket.payments.get should contain(paymentId1) basket.payments.get should contain(paymentId2) - // Verify each paymentId in the basket has ACCP status - Then("Each payment in the basket should return ACCP status") + // Each payment still awaits SCA, which Berlin Group reports as RCVD (ACCP would mean it is already booked) + Then("Each payment in the basket should return RCVD status") basket.payments.get.foreach { pid => val requestPaymentStatus = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / pid / "status").GET <@ (user1) val responsePaymentStatus = makeGetRequest(requestPaymentStatus) responsePaymentStatus.code should be(200) val txStatus = (responsePaymentStatus.body \ "transactionStatus").extract[String] - txStatus should be(TransactionStatus.ACCP.code) + txStatus should be(TransactionStatus.RCVD.code) } } } @@ -265,84 +393,389 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def } - feature(s"BG v1.3 - $createSigningBasket, $getSigningBasket, $getSigningBasketStatus, $deleteSigningBasket, $startSigningBasketAuthorisation, $getSigningBasketAuthorisation, $updateSigningBasketPsuData") { - scenario("Authentication User, test succeed", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, startSigningBasketAuthorisation, getSigningBasketAuthorisation, updateSigningBasketPsuData) { - // Create Signing Basket - val postJson = - s"""{ - | "paymentIds": [ - | "123qwert456789", - | "12345qwert7899" - | ] - |}""".stripMargin - - val requestPost = (V1_3_BG / "signing-baskets").POST <@ (user1) - val response: APIResponse = makePostRequest(requestPost, postJson) - Then("We should get a 201 ") - response.code should equal(201) + // ───────────────────────── the happy path, with real payments ───────────────────────── - val basketId = response.body.extract[SigningBasketResponseJson].basketId + feature(s"BG v1.3 - $createSigningBasket, $getSigningBasket, $getSigningBasketStatus, $deleteSigningBasket, $startSigningBasketAuthorisation, $getSigningBasketAuthorisation, $getSigningBasketScaStatus, $updateSigningBasketPsuData") { + scenario("a basket of real payments is created, read, authorised and its authorisation listed", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus, startSigningBasketAuthorisation, getSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket(paymentCount = 2) - // Get Signing Basket Then(s"We test the $getSigningBasket") - val requestGet = (V1_3_BG / "signing-baskets" / basketId).GET <@ (user1) - val responseGet = makeGetRequest(requestGet) + val responseGet = makeGetRequest(basketUrl(started.basketId).GET <@ (user1)) responseGet.code should be(200) - responseGet.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + responseGet.body.extract[SigningBasketGetResponseJson].transactionStatus should be("RCVD") // L4497-4518 - // Get Signing Basket Status Then(s"We test the $getSigningBasketStatus") - val requestGetStatus = (V1_3_BG / "signing-baskets" / basketId / "status").GET <@ (user1) - var responseGetStatus = makeGetRequest(requestGetStatus) - responseGetStatus.code should be(200) - responseGetStatus.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) - - // Delete Signing Basket - val requestDelete = (V1_3_BG / "signing-baskets" / basketId).DELETE <@ (user1) - val responseDelete = makeDeleteRequest(requestDelete) - responseDelete.code should be(204) - - responseGetStatus = makeGetRequest(requestGetStatus) - responseGetStatus.code should be(200) - responseGetStatus.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.CANC.toString.toLowerCase()) - - // Start Signing Basket Auth Flow - val postJsonAuth = s"""{}""".stripMargin - val requestAuth = (V1_3_BG / "signing-baskets" / basketId / "authorisations").POST <@ (user1) - val responseAuth = makePostRequest(requestAuth, postJsonAuth) - Then("We should get a 201 ") - responseAuth.code should equal(201) - responseAuth.body.extract[StartPaymentAuthorisationJson].scaStatus should - be(StrongCustomerAuthenticationStatus.received.toString) - val authorisationId = responseAuth.body.extract[StartPaymentAuthorisationJson].authorisationId - - // Get Signing Basket Auth Flow Status - val requestAuthStatus = (V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId).GET <@ (user1) - val responseAuthStatus = makeGetRequest(requestAuthStatus) - Then("We should get a 200 ") - responseAuthStatus.code should equal(200) - responseAuthStatus.body.extract[ScaStatusJsonV13].scaStatus should - be(responseAuth.body.extract[StartPaymentAuthorisationJson].scaStatus) - - // Get Signing Basket Authorisations - val requestGetAuths = (V1_3_BG / "signing-baskets" / "basketId" / "authorisations").GET <@ (user1) - val responseGetAuths = makeGetRequest(requestGetAuths) - Then("We should get a 200 ") - responseGetAuths.code should equal(200) - responseGetAuths.body.extract[AuthorisationJsonV13] - - // Failed due to unexisting paymentIds - val putJson = s"""{"scaAuthenticationData":"123"}""".stripMargin - val requestPut = (V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId).PUT <@ (user1) - val responsePut = makePutRequest(requestPut, putJson) - val error = s"$InvalidConnectorResponse" - And("error should be " + error) - responsePut.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith(error) + val responseStatus = makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user1)) + responseStatus.code should be(200) + (responseStatus.body \ "transactionStatus").extract[String] should be("RCVD") + + Then(s"We test the $getSigningBasketAuthorisation") + val responseAuths = makeGetRequest(authorisationsUrl(started.basketId).GET <@ (user1)) + responseAuths.code should be(200) + responseAuths.body.extract[AuthorisationJsonV13].authorisationIds should equal(List(started.authorisationId)) // L4827 + + Then(s"We test the $getSigningBasketScaStatus") + val responseAuthStatus = makeGetRequest(authorisationUrl(started.basketId, started.authorisationId).GET <@ (user1)) + responseAuthStatus.code should be(200) + (responseAuthStatus.body \ "scaStatus").extract[String] should be(StrongCustomerAuthenticationStatus.received.toString) + } + } + + // ───────────────────────── response shape: C1, C2, C3, C12 ───────────────────────── + + feature("BG v1.3 signing baskets - response shape follows the standard") { + scenario("C1: transactionStatus is upper case in every response that carries it (L4497-4518)", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus) { + val response = postBasket(s"""{"paymentIds":${idList(List(lodgePayment()))}}""") + response.code should equal(201) + (response.body \ "transactionStatus").extract[String] should equal("RCVD") + val basketId = response.body.extract[SigningBasketResponseJson].basketId + + (makeGetRequest(basketUrl(basketId).GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + (makeGetRequest((basketUrl(basketId) / "status").GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + } + + scenario("C12: the 201 carries Location (IG §8.1, Mandatory) and ASPSP-SCA-Approach (IG §8.1, Conditional on the approach being fixed)", BerlinGroupV1_3, SBS, createSigningBasket) { + val response = postBasket(s"""{"paymentIds":${idList(List(lodgePayment()))}}""") + response.code should equal(201) + val basketId = response.body.extract[SigningBasketResponseJson].basketId + val headers = response.headers.getOrElse(fail("the response has no headers")) + Option(headers.get("Location")).getOrElse(fail("Location is missing")) should endWith(s"/signing-baskets/$basketId") + Option(headers.get("ASPSP-SCA-Approach")) should not be empty + } + + scenario("C2: _links.scaStatus of a started authorisation is a {href} object (L4801, L10752)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + val response = startAuthorisation(basketId) + response.code should equal(201) + val authorisationId = (response.body \ "authorisationId").extract[String] + (response.body \ "scaStatus").extract[String] should equal("received") + (response.body \ "_links" \ "scaStatus" \ "href").extract[String] should endWith(s"/signing-baskets/$basketId/authorisations/$authorisationId") + } + + scenario("C3: the answer to an authorisation links to the basket's authorisation, not to a payment (L8828, L15675)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + val response = answerAuthorisation(started.basketId, started.authorisationId) + response.code should equal(200) + (response.body \ "scaStatus").extract[String] should equal("finalised") + val href = (response.body \ "_links" \ "scaStatus" \ "href").extract[String] + href should endWith(s"/signing-baskets/${started.basketId}/authorisations/${started.authorisationId}") + href should not include "/payments/" } } + // ───────────────────────── request validation: C5, C7 ───────────────────────── + + feature("BG v1.3 signing baskets - requests are validated against the schema") { + scenario("C5: an empty id list is refused, whichever list it is (L4325, L4365; body 'shall contain at least one entry' L4742)", BerlinGroupV1_3, SBS, createSigningBasket) { + val payment = lodgePayment() + val basketsBefore = storedBasketCount() + List( + """{"paymentIds":[]}""", + """{"consentIds":[]}""", + """{"paymentIds":[],"consentIds":[]}""", + s"""{"paymentIds":${idList(List(payment))},"consentIds":[]}""" + ).foreach { body => + expectRefusal(postBasket(body), 400, "FORMAT_ERROR", s"body $body") + } + withClue("a refused request leaves no basket behind: ") { storedBasketCount() should equal(basketsBefore) } + } + + scenario("C5: the same id twice in one list is refused (the standard sets no rule; refused as a format error)", BerlinGroupV1_3, SBS, createSigningBasket) { + val payment = lodgePayment() + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(payment, payment))}}"""), 400, "FORMAT_ERROR", "duplicate payment id") + } + + scenario("C7: POST authorisations refuses the body variants it does not support instead of discarding them (L3653)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + expectRefusal(startAuthorisation(basketId, body = """{"psuData":{"password":"secret"}}"""), 400, "SERVICE_INVALID", "updatePsuAuthentication") + expectRefusal(startAuthorisation(basketId, body = """{"authenticationMethodId":"sms"}"""), 400, "SERVICE_INVALID", "selectPsuAuthenticationMethod") + withClue("neither refused request minted a challenge: ") { storedChallengeCount(basketId) should equal(0) } + } + + scenario("C7: POST authorisations accepts the two variants it supports (L3653)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + startAuthorisation(basketId, body = "{}").code should equal(201) + startAuthorisation(basketId, body = """{"scaAuthenticationData":"123"}""").code should equal(201) + } -} \ No newline at end of file + scenario("C7: PUT refuses the variants it does not support, and a body matching no variant is a format error (L3867, L8250-8300)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId, body = """{"confirmationCode":"123"}"""), 400, "SERVICE_INVALID", "authorisationConfirmation") + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId, body = """{"psuData":{"password":"x"}}"""), 400, "SERVICE_INVALID", "updatePsuAuthentication") + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId, body = """{"foo":"bar"}"""), 400, "FORMAT_ERROR", "matches no variant") + withClue("nothing was authorised: ") { + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + } + } + + // ───────────────────────── states and transitions: C6, C9, C10 ───────────────────────── + + feature("BG v1.3 signing baskets - a basket only moves along the transitions the standard and this ASPSP allow") { + scenario("C9: a deleted basket cannot be authorised, and nothing it held is touched (L3399-3403)", BerlinGroupV1_3, SBS, deleteSigningBasket, startSigningBasketAuthorisation, updateSigningBasketPsuData) { + val started = startedBasket() + makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)).code should equal(204) + storedBasketStatus(started.basketId) should equal(Some("CANC")) + + expectRefusal(startAuthorisation(started.basketId), 409, "STATUS_INVALID", "starting an authorisation on a CANC basket") + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 409, "STATUS_INVALID", "answering an authorisation on a CANC basket") + withClue("the basket stayed CANC and its payment was not touched: ") { + storedBasketStatus(started.basketId) should equal(Some("CANC")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + withClue("deleting a deleted basket is idempotent: ") { + makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)).code should equal(204) + } + } + + scenario("C6: a basket whose authorisation has been applied cannot be deleted or restarted (L3399-3403)", BerlinGroupV1_3, SBS, deleteSigningBasket, startSigningBasketAuthorisation, updateSigningBasketPsuData) { + val started = startedBasket() + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + + expectRefusal(makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)), 409, "STATUS_INVALID", "deleting an authorised basket") + expectRefusal(startAuthorisation(started.basketId), 409, "STATUS_INVALID", "starting another authorisation on an authorised basket") + withClue("the basket is still ACTC, not CANC: ") { storedBasketStatus(started.basketId) should equal(Some("ACTC")) } + } + + scenario("C6: a started but unanswered authorisation does not stop a delete (L3399-3403)", BerlinGroupV1_3, SBS, deleteSigningBasket) { + val started = startedBasket() + makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)).code should equal(204) + storedBasketStatus(started.basketId) should equal(Some("CANC")) + } + + scenario("C9: answering the same authorisation twice is a conflict and does not repeat anything", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 409, "STATUS_INVALID", "the repeated answer") + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + } + + scenario("C10: a basket with a consent member is refused at authorisation before anything changes (consent activation is not supported yet)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val consentResponse = makePostRequest((V1_3_BG / "consents").POST <@ (user1), write(bgConsentPostBody())) + consentResponse.code should equal(201) + val consentId = (consentResponse.body \ "consentId").extract[String] + val payment = lodgePayment() + + val consentOnly = postBasket(s"""{"consentIds":${idList(List(consentId))}}""") + consentOnly.code should equal(201) + val consentOnlyBasket = consentOnly.body.extract[SigningBasketResponseJson].basketId + val consentOnlyAuth = (startAuthorisation(consentOnlyBasket).body \ "authorisationId").extract[String] + expectRefusal(answerAuthorisation(consentOnlyBasket, consentOnlyAuth), 400, "SERVICE_INVALID", "authorising a consent-only basket") + storedBasketStatus(consentOnlyBasket) should equal(Some("RCVD")) + + // The consent is held by the first basket, so a mixed basket needs another one. + val secondConsent = makePostRequest((V1_3_BG / "consents").POST <@ (user1), write(bgConsentPostBody())) + val secondConsentId = (secondConsent.body \ "consentId").extract[String] + val mixed = postBasket(s"""{"paymentIds":${idList(List(payment))},"consentIds":${idList(List(secondConsentId))}}""") + mixed.code should equal(201) + val mixedBasket = mixed.body.extract[SigningBasketResponseJson].basketId + val mixedAuth = (startAuthorisation(mixedBasket).body \ "authorisationId").extract[String] + expectRefusal(answerAuthorisation(mixedBasket, mixedAuth), 400, "SERVICE_INVALID", "authorising a mixed basket") + withClue("the payment was not marked completed and the basket was not marked ACTC: ") { + storedBasketStatus(mixedBasket) should equal(Some("RCVD")) + storedPaymentStatus(payment) should equal(awaitingSca) + } + } + + // Pending until consent activation is specified; the target is recorded so it is not forgotten. + ignore("C10 (target, execution phase): authorising a consent-only basket leaves the consent valid", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) {} + } + + // ───────────────────────── unknown resources: C4, C8, C11 ───────────────────────── + + feature("BG v1.3 signing baskets - unknown resources are refused with codes the standard allows") { + scenario("C8/D1: an unknown basket is answered 403 RESOURCE_UNKNOWN by every operation that names one (L11648, L11680)", BerlinGroupV1_3, SBS, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, getSigningBasketAuthorisation, startSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val unknown = UUID.randomUUID().toString + val unknownAuthorisation = UUID.randomUUID().toString + val challengesBefore = Challenges.ChallengeProvider.vend.getChallengesByBasketId(unknown).map(_.size).openOrThrowException("x") + List( + "GET basket" -> makeGetRequest(basketUrl(unknown).GET <@ (user1)), + "GET status" -> makeGetRequest((basketUrl(unknown) / "status").GET <@ (user1)), + "DELETE basket" -> makeDeleteRequest(basketUrl(unknown).DELETE <@ (user1)), + "GET authorisations" -> makeGetRequest(authorisationsUrl(unknown).GET <@ (user1)), + "POST authorisation" -> startAuthorisation(unknown), + "GET authorisation" -> makeGetRequest(authorisationUrl(unknown, unknownAuthorisation).GET <@ (user1)), + "PUT authorisation" -> answerAuthorisation(unknown, unknownAuthorisation) + ).foreach { case (what, response) => expectRefusal(response, 403, "RESOURCE_UNKNOWN", what) } + withClue("starting an authorisation on a basket that does not exist minted no challenge: ") { + Challenges.ChallengeProvider.vend.getChallengesByBasketId(unknown).map(_.size).openOrThrowException("x") should equal(challengesBefore) + } + } + + scenario("C4: an authorisation id the basket does not have is 404 RESOURCE_UNKNOWN, never a 200 with a made-up scaStatus (L4521, L11680)", BerlinGroupV1_3, SBS, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket() + val other = startedBasket() + expectRefusal(makeGetRequest(authorisationUrl(started.basketId, UUID.randomUUID().toString).GET <@ (user1)), 404, "RESOURCE_UNKNOWN", "an id nobody issued") + expectRefusal(makeGetRequest(authorisationUrl(started.basketId, other.authorisationId).GET <@ (user1)), 404, "RESOURCE_UNKNOWN", "an id issued for another basket") + expectRefusal(answerAuthorisation(started.basketId, other.authorisationId), 404, "RESOURCE_UNKNOWN", "answering an id issued for another basket") + } + + scenario("C11: a refused creation uses codes from the standard's lists (L11514, L11744, IG §14.11.5)", BerlinGroupV1_3, SBS, createSigningBasket) { + val invented = UUID.randomUUID().toString + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(invented))}}"""), 400, "RESOURCE_UNKNOWN", "invented payment id") + expectRefusal(postBasket("""{"wrongFieldName":["x"]}"""), 400, "FORMAT_ERROR", "unknown field") + } + } + + // ───────────────────────── ownership: S1 ───────────────────────── + + feature("BG v1.3 signing baskets - a basket belongs to the TPP that created it") { + scenario("S1: a second TPP is refused on every operation, and nothing about the basket changes (IG §4.11)", BerlinGroupV1_3, SBS, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, getSigningBasketAuthorisation, startSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket() + val challengesBefore = storedChallengeCount(started.basketId) + + List( + "GET basket" -> makeGetRequest(basketUrl(started.basketId).GET <@ (user2)), + "GET status" -> makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user2)), + "DELETE basket" -> makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user2)), + "GET authorisations" -> makeGetRequest(authorisationsUrl(started.basketId).GET <@ (user2)), + "POST authorisation" -> startAuthorisation(started.basketId, as = user2), + "GET authorisation" -> makeGetRequest(authorisationUrl(started.basketId, started.authorisationId).GET <@ (user2)), + "PUT authorisation" -> answerAuthorisation(started.basketId, started.authorisationId, as = user2) + ).foreach { case (what, response) => expectRefusal(response, 403, "RESOURCE_UNKNOWN", s"user2 tried to $what") } + + withClue("the basket, its payments and its challenges are as they were: ") { + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + storedChallengeCount(started.basketId) should equal(challengesBefore) + } + And("the TPP that created it still can") + makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user1)).code should equal(200) + } + + scenario("S1: the same PSU acting through a second TPP is refused too (IG §4.11)", BerlinGroupV1_3, SBS, getSigningBasketStatus, deleteSigningBasket) { + val basketId = createBasket(List(lodgePayment())) + expectRefusal(makeGetRequest((basketUrl(basketId) / "status").GET <@ (samePsuUnderSecondConsumer)), 403, "RESOURCE_UNKNOWN", "status read") + expectRefusal(makeDeleteRequest(basketUrl(basketId).DELETE <@ (samePsuUnderSecondConsumer)), 403, "RESOURCE_UNKNOWN", "delete") + storedBasketStatus(basketId) should equal(Some("RCVD")) + } + + scenario("S1: a basket created before ownership was recorded is quarantined, for everybody", BerlinGroupV1_3, SBS, getSigningBasket, deleteSigningBasket, startSigningBasketAuthorisation) { + // The shape every basket had before ownership existed: a status, members, and no consumer or PSU. + val payment = lodgePayment() + val legacy = MappedSigningBasket.create.Status("RCVD").saveMe() + MappedSigningBasketPayment.create.BasketId(legacy.basketId).PaymentId(payment).saveMe() + + List( + "GET basket" -> makeGetRequest(basketUrl(legacy.basketId).GET <@ (user1)), + "DELETE basket" -> makeDeleteRequest(basketUrl(legacy.basketId).DELETE <@ (user1)), + "POST authorisation" -> startAuthorisation(legacy.basketId) + ).foreach { case (what, response) => expectRefusal(response, 403, "RESOURCE_UNKNOWN", s"the payment's own TPP tried to $what on a legacy basket") } + withClue("the legacy basket is kept as it was, for audit: ") { + storedBasketStatus(legacy.basketId) should equal(Some("RCVD")) + storedChallengeCount(legacy.basketId) should equal(0) + } + } + } + + // ───────────────────────── members: S5, D8 ───────────────────────── + + feature("BG v1.3 signing baskets - a basket only takes members the caller may authorise") { + scenario("S5: an id that names no payment is refused, and no basket is left behind", BerlinGroupV1_3, SBS, createSigningBasket) { + val basketsBefore = storedBasketCount() + expectRefusal(postBasket(s"""{"paymentIds":${idList(List("123qwert456789", "12345qwert7899"))}}"""), 400, "RESOURCE_UNKNOWN", "invented payment ids") + storedBasketCount() should equal(basketsBefore) + } + + scenario("S5: a payment another TPP lodged looks exactly like one that does not exist", BerlinGroupV1_3, SBS, createSigningBasket) { + val payment = lodgePayment() // lodged by user1 + val basketsBefore = storedBasketCount() + val foreign = postBasket(s"""{"paymentIds":${idList(List(payment))}}""", as = user2) + val invented = postBasket(s"""{"paymentIds":${idList(List(UUID.randomUUID().toString))}}""", as = user2) + expectRefusal(foreign, 400, "RESOURCE_UNKNOWN", "another TPP's payment") + expectRefusal(invented, 400, "RESOURCE_UNKNOWN", "an invented payment") + storedBasketCount() should equal(basketsBefore) + } + + scenario("D8: a payment that is already booked cannot be put in a basket (IG §14.11.5, L11748)", BerlinGroupV1_3, SBS, createSigningBasket) { + val booked = lodgePayment(amount = "10") // under the threshold: booked on creation + storedPaymentStatus(booked) should equal(bookedOnCreation) + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(booked))}}"""), 409, "REFERENCE_STATUS_INVALID", "a booked payment") + } + + scenario("D8: a payment sits in one active basket at a time, and is released when that basket is cancelled", BerlinGroupV1_3, SBS, createSigningBasket, deleteSigningBasket) { + val payment = lodgePayment() + val first = createBasket(List(payment)) + val basketsBefore = storedBasketCount() + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(payment))}}"""), 409, "REFERENCE_STATUS_INVALID", "the same payment in a second active basket") + withClue("the refused request left no basket behind: ") { storedBasketCount() should equal(basketsBefore) } + + makeDeleteRequest(basketUrl(first).DELETE <@ (user1)).code should equal(204) + postBasket(s"""{"paymentIds":${idList(List(payment))}}""").code should equal(201) + } + } + + // ───────────────────────── challenge binding and ordering: S3 ───────────────────────── + + feature("BG v1.3 signing baskets - an authorisation can only be answered through the basket it was issued for") { + scenario("S3: a challenge that was finalised for one basket cannot be replayed to execute another (SB PUT order)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val first = startedBasket() + val second = startedBasket() + answerAuthorisation(first.basketId, first.authorisationId).code should equal(200) // finalises first's challenge + + // The challenge already answered is presented, with a wrong answer, against the other basket. + val replay = answerAuthorisation(second.basketId, first.authorisationId, body = """{"scaAuthenticationData":"wrong"}""") + replay.code should be >= 400 + withClue("the second basket and its payment are untouched, whatever the response said: ") { + storedBasketStatus(second.basketId) should equal(Some("RCVD")) + second.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + } + + scenario("S3: a wrong answer changes nothing", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + val response = answerAuthorisation(started.basketId, started.authorisationId, body = """{"scaAuthenticationData":"wrong"}""") + response.code should be >= 400 + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + } + + // ───────────────────────── execution: S4 (the next phase, recorded here so it is not forgotten) ───────────────────────── + + feature("BG v1.3 signing baskets - a payment the basket reports as authorised is actually booked") { + // Ignored until payment execution is reworked (the next phase). Measured on the baseline: the final answer + // returns 200, the payment is stored COMPLETED and the basket ACTC, and neither account moves, even after + // eight seconds. The booking is started without being awaited and its outcome is never read. + ignore("S4 (target, execution phase): after the final answer the debtor account is debited and the creditor account credited", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val started = startedBasket() + val fromBefore = balanceOf(ibanFrom) + val toBefore = balanceOf(ibanTo) + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + val deadline = System.currentTimeMillis() + 8000 + while (System.currentTimeMillis() < deadline && balanceOf(ibanTo) == toBefore) Thread.sleep(250) + withClue(s"payment status ${storedPaymentStatus(started.paymentIds.head)}, basket ${storedBasketStatus(started.basketId)}: ") { + balanceOf(ibanFrom) should equal(fromBefore - 2001) + balanceOf(ibanTo) should equal(toBefore + 2001) + } + } + } + + // ───────────────────────── concurrency ───────────────────────── + + feature("BG v1.3 signing baskets - a delete racing the final answer has exactly one winner") { + scenario("S2: PUT and DELETE at the same time never both succeed", BerlinGroupV1_3, SBS, deleteSigningBasket, updateSigningBasketPsuData) { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 5).foreach { round => + val started = startedBasket() + val put = Future(answerAuthorisation(started.basketId, started.authorisationId)) + val delete = Future(makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1))) + val (putResponse, deleteResponse) = (Await.result(put, 60.seconds), Await.result(delete, 60.seconds)) + withClue(s"round $round (put ${putResponse.code}, delete ${deleteResponse.code}): ") { + (putResponse.code == 200 && deleteResponse.code == 204) should be(false) + storedBasketStatus(started.basketId) match { + case Some("CANC") => started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + case Some("ACTC") => deleteResponse.code should equal(409) + case other => fail(s"the basket ended in $other") + } + } + } + } + } +} From b18930d5568e32c3a2c5d27f452937c633120665 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:21:45 +0200 Subject: [PATCH 02/40] fix: report signing basket transactionStatus in upper case The standard's enumeration for baskets (RCVD, PATC, ACTC, CANC, RJCT) is upper case. The create, get and status responses lower-cased the stored value. Also correct the ResourceDoc examples, which showed ACCP, a code that is not used for baskets. TPPs that matched the lower-case strings must now match the upper-case ones. --- .../api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala | 4 ++-- .../berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index aa8ca2788e..8b48bdaeb6 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -134,7 +134,7 @@ The resource identifications of these transactions are contained in the payload "status" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983" }, "chosenScaMethod" : "", - "transactionStatus" : "ACCP", + "transactionStatus" : "RCVD", "psuMessage" : { } }""")), List(AuthenticatedUserIsRequired, UnknownError), @@ -202,7 +202,7 @@ Nevertheless, single transactions might be cancelled on an individual basis on t Returns the content of an signing basket object.""", EmptyBody, JvalueCaseClass(json.parse("""{ - "transactionStatus" : "ACCP", + "transactionStatus" : "RCVD", "payments" : "", "consents" : "" }""")), diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index 1d4eaa2a10..a9b9d87f64 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -887,7 +887,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def createSigningBasketResponseJson(basket: SigningBasketTrait): SigningBasketResponseJson = { SigningBasketResponseJson( basketId = basket.basketId, - transactionStatus = basket.status.toLowerCase(), + transactionStatus = basket.status, _links = SigningBasketLinksV13( self = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}"), status = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}/status"), @@ -898,7 +898,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status.toLowerCase(), + transactionStatus = basket.basket.status, payments = basket.payments, consents = basket.consents, ) @@ -906,7 +906,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketStatusResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status.toLowerCase(), + transactionStatus = basket.basket.status, payments = None, consents = None, ) From 006e0e17840936bb76caa14b1b43a399b7d14253 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:22:57 +0200 Subject: [PATCH 03/40] fix: return the signing basket scaStatus link as a hyperlink object The start-authorisation response carried _links.scaStatus as a bare string; the standard defines it as a hrefType object ({"href": ...}). Use a basket-specific response type so the payment and consent authorisation responses, which share the old type, are left alone, and replace the ResourceDoc example, which listed links the endpoint never returns. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 28 +++++-------------- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 15 ++++++++-- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 8b48bdaeb6..10619965a2 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -385,28 +385,14 @@ This applies in the following scenarios: """, EmptyBody, JvalueCaseClass(json.parse("""{ - "challengeData" : { - "otpMaxLength" : 0, - "additionalInformation" : "additionalInformation", - "image" : "image", - "imageLink" : "http://example.com/aeiou", - "otpFormat" : "characters", - "data" : "data" - }, - "scaMethods" : "", - "scaStatus" : "psuAuthenticated", + "scaStatus" : "received", + "authorisationId" : "4f4a8b7f-9968-4183-92ab-ca512b396bfc", + "psuMessage" : "Please check your SMS at a mobile device.", "_links" : { - "scaStatus" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "startAuthorisationWithEncryptedPsuAuthentication" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "scaRedirect" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "selectAuthenticationMethod" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "startAuthorisationWithPsuAuthentication" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "authoriseTransaction" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "scaOAuth" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983", - "updatePsuIdentification" : "/v1.3/payments/sepa-credit-transfers/1234-wertiq-983" - }, - "chosenScaMethod" : "", - "psuMessage" : { } + "scaStatus" : { + "href" : "/v1.3/signing-baskets/1234-basket-567/authorisations/4f4a8b7f-9968-4183-92ab-ca512b396bfc" + } + } }""")), List(AuthenticatedUserIsRequired, UnknownError), apiTagSigningBaskets :: Nil, diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index a9b9d87f64..d3c12837b1 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -70,6 +70,13 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ transactionStatus: String, basketId: String, _links: SigningBasketLinksV13) + // The links of a signing basket authorisation: scaStatus is a hyperlink object (hrefType), not a bare string. + case class SigningBasketScaLinksV13(scaStatus: LinkHrefJson) + case class StartSigningBasketAuthorisationJson( + scaStatus: String, + authorisationId: String, + psuMessage: String, + _links: SigningBasketScaLinksV13) case class SigningBasketGetResponseJson( transactionStatus: String, payments: Option[List[String]], @@ -875,12 +882,14 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ } - def createStartSigningBasketAuthorisationJson(basketId: String, challenge: ChallengeTrait): StartPaymentAuthorisationJson = { - StartPaymentAuthorisationJson( + def createStartSigningBasketAuthorisationJson(basketId: String, challenge: ChallengeTrait): StartSigningBasketAuthorisationJson = { + StartSigningBasketAuthorisationJson( scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""), authorisationId = challenge.challengeId, psuMessage = "Please check your SMS at a mobile device.", - _links = ScaStatusJsonV13(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}") + _links = SigningBasketScaLinksV13( + scaStatus = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}") + ) ) } From bcdc6af1a7206e5a50e9d6a0b137c58c0c8313e3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:24:04 +0200 Subject: [PATCH 04/40] fix: answer a signing basket authorisation with a basket response The PUT on a basket authorisation reused the payment response builder, so its _links.scaStatus pointed at /payments/sepa-credit-transfers/{id}. Build the scaStatusResponse for the basket instead: scaStatus plus a {href} link to /signing-baskets/{basketId}/authorisations/{id}. The body no longer carries authorisationId, which scaStatusResponse does not define. Correct the ResourceDoc example to match. --- .../berlin/group/v1_3/Http4sBGv13SigningBaskets.scala | 7 ++++--- .../group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 10619965a2..9fcccbf946 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -465,7 +465,7 @@ This applies in the following scenarios: Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) } } yield { - JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge) } } } @@ -521,10 +521,11 @@ There are the following request types on this access path: JvalueCaseClass(json.parse("""{"scaAuthenticationData":"123"}""")), JvalueCaseClass(json.parse("""{ "scaStatus":"finalised", - "authorisationId":"4f4a8b7f-9968-4183-92ab-ca512b396bfc", "psuMessage":"Please check your SMS at a mobile device.", "_links":{ - "scaStatus":"/v1.3/payments/sepa-credit-transfers/PAYMENT_ID/4f4a8b7f-9968-4183-92ab-ca512b396bfc" + "scaStatus":{ + "href":"/v1.3/signing-baskets/1234-basket-567/authorisations/4f4a8b7f-9968-4183-92ab-ca512b396bfc" + } } }""")), List(AuthenticatedUserIsRequired, UnknownError), diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index d3c12837b1..da55c706f9 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -893,6 +893,16 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ ) } + /** The 200 answer to a transaction authorisation on a signing basket: a scaStatusResponse whose link names the basket's authorisation. */ + def createUpdateSigningBasketPsuDataJson(basketId: String, challenge: ChallengeTrait) = { + ScaStatusResponse( + scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""), + psuMessage = Some("Please check your SMS at a mobile device."), + _links = Some(LinksAll(scaStatus = Some(HrefType(Some( + s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}"))))) + ) + } + def createSigningBasketResponseJson(basket: SigningBasketTrait): SigningBasketResponseJson = { SigningBasketResponseJson( basketId = basket.basketId, From 37dd21145eb03c726c3e772920a92dc0f15421eb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:25:04 +0200 Subject: [PATCH 05/40] fix: refuse empty and duplicate id lists when creating a signing basket Each list in the body has minItems 1 and the body must carry at least one entry. A request with {"paymentIds": []} was accepted and produced an empty basket. Refuse an empty list, and a list that names the same id twice, with the format error the endpoint already uses for a malformed body. --- .../api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 9fcccbf946..7accc8b955 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -85,8 +85,12 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { postJson <- NewStyle.function.tryons(failMsg, 400, callContext) { json.parse(cc.httpBody.getOrElse("")).extract[PostSigningBasketJsonV13] } + // The body shall contain at least one entry, and each list that is present at least one id + // (minItems: 1). A list naming the same id twice is refused as well, rather than silently + // collapsed, so the TPP learns its request was malformed. + idLists = List(postJson.paymentIds, postJson.consentIds).flatten _ <- booleanToFuture(failMsg, cc = callContext) { - !(postJson.paymentIds.isEmpty && postJson.consentIds.isEmpty) + idLists.nonEmpty && idLists.forall(ids => ids.nonEmpty && ids.distinct.size == ids.size) } signingBasket <- Future { SigningBasketX.signingBasketProvider.vend.createSigningBasket( From d2cc705b5dba7add653a6687f1ef589e798bdb02 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:26:28 +0200 Subject: [PATCH 06/40] fix: refuse signing basket authorisation bodies that are not supported POST authorisations never read its body, so a request carrying PSU credentials (updatePsuAuthentication) or an authentication method choice was answered with a fresh challenge and the data discarded. The PUT treated any body without scaAuthenticationData as malformed, giving the confirmation-code and PSU-data variants a format error instead of a statement that they are unsupported. Dispatch on the body variant: an empty or transactionAuthorisation body is accepted; updatePsuAuthentication, selectPsuAuthenticationMethod and authorisationConfirmation are refused with a new error (OBP-35050, reported as SERVICE_INVALID); anything else stays a format error. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 27 ++++++++++++++++++- .../code/api/util/BerlinGroupError.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 3 +++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 7accc8b955..a9989f8348 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -36,7 +36,7 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, ge import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats -import code.api.util.{ApiTag, NewStyle} +import code.api.util.{ApiTag, CallContext, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.SigningBasketNewStyle import code.bankconnectors.Connector @@ -73,6 +73,27 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + /** + * Berlin Group hangs several request bodies off the authorisation paths (L3653, L3867). Baskets + * support the two that need no data this ASPSP holds back: an empty body, which starts the + * authorisation, and `transactionAuthorisation`, which answers it. The others are Embedded-approach + * steps (PSU authentication, authentication method selection, confirmation code) that are not + * implemented for any Berlin Group resource here. They are refused by name rather than answered + * as if the credential or the choice had been processed, and a body that matches no variant is a + * format error. + */ + private def requireSupportedAuthorisationBody(rawBody: String, answering: Boolean, failMsg: String, callContext: Option[CallContext]): Future[Boolean] = { + val parsed = scala.util.Try(json.parse(rawBody)).getOrElse(json.JNothing) + val supported = if (answering) checkTransactionAuthorisation(parsed) else startsAuthorisation(parsed) + val knownButUnsupported = !supported && ( + checkUpdatePsuAuthentication(parsed) || checkSelectPsuAuthenticationMethod(parsed) || + checkAuthorisationConfirmation(parsed) || (answering && parsed == json.JObject(Nil))) + for { + _ <- booleanToFuture(SigningBasketAuthorisationVariantNotSupported, cc = callContext)(!knownButUnsupported) + _ <- booleanToFuture(failMsg, cc = callContext)(supported) + } yield true + } + // ── POST /signing-baskets ────────────────────────────────────────────── val createSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `bgV13Prefix` / "signing-baskets" => @@ -327,6 +348,9 @@ Returns the status of a signing basket object. val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + _ <- requireSupportedAuthorisationBody( + cc.httpBody.getOrElse(""), answering = false, + s"$InvalidJsonFormat The Json body should be empty, or the transactionAuthorisation body. ", callContext) (challenges, _) <- NewStyle.function.createChallengesC3( List(cc.user.map(_.userId).openOr("")), ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, @@ -411,6 +435,7 @@ This applies in the following scenarios: for { _ <- passesPsd2Pisp(callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " + _ <- requireSupportedAuthorisationBody(cc.httpBody.getOrElse(""), answering = true, failMsg, callContext) updateBasketPsuDataJson <- NewStyle.function.tryons(failMsg, 400, callContext) { json.parse(cc.httpBody.getOrElse("")).extract[UpdatePaymentPsuDataJson] } diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index cadb2c65cc..b110a9e022 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -115,6 +115,7 @@ object BerlinGroupError { case "403" if message.contains("OBP-20060") => "ROLE_INVALID" case "400" if message.contains("OBP-10034") => "PARAMETER_NOT_CONSISTENT" + case "400" if message.contains("OBP-35050") => "SERVICE_INVALID" case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN" case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN" 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 206515e69d..7e688d8b02 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -876,6 +876,9 @@ object ErrorMessages { val InvalidUKConsentPermissions = "OBP-35038: The Permissions array is not a valid combination for UK Open Banking. " val BerlinGroupPsuNotIdentified = "OBP-35039: The PSU this authorisation is for cannot be identified. Send the PSU-ID header, or authenticate as the PSU. " val ConsentNamesNoAccount = "OBP-35040: The Consent names no account, so it grants no access. It was authorised before consents were bound to accounts; re-authorise it to select which accounts it applies to. " + val SigningBasketAuthorisationVariantNotSupported = "OBP-35050: This request body is not supported on a signing basket authorisation. " + + "Send an empty body to start the authorisation, or {\"scaAuthenticationData\": ...} to answer it. " + + "PSU authentication, authentication method selection and confirmation code requests are not available for signing baskets. " val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. " val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. " val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. " From 53804db9c6f914d1faefb5272bb8d18a0a518a03 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:28:50 +0200 Subject: [PATCH 07/40] fix: send Location and ASPSP-SCA-Approach when a signing basket is created The Implementation Guidelines make Location mandatory on a created resource and require ASPSP-SCA-Approach when the approach is fixed, as it is per instance. Neither was sent: Location by no Berlin Group endpoint, and ASPSP-SCA-Approach only on URLs ending in /consents. Send Location on POST /signing-baskets, built from the path the request arrived on so alias paths are honoured, and ASPSP-SCA-Approach on the two calls that create a basket resource. Add an additive response helper that lets a handler derive headers from the created resource. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 5 ++++- .../main/scala/code/api/util/APIUtil.scala | 7 +++++++ .../code/api/util/http4s/Http4sSupport.scala | 19 +++++++++++++++++++ .../v1_3/SigningBasketServiceSBSApiTest.scala | 3 +++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index a9989f8348..a3dec2fd32 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -97,7 +97,7 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { // ── POST /signing-baskets ────────────────────────────────────────────── val createSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `bgV13Prefix` / "signing-baskets" => - EndpointHelpers.executeFutureCreated(req) { + EndpointHelpers.executeFutureCreatedWithHeaders(req) { val cc = req.callContext val callContext = Some(cc) for { @@ -122,6 +122,9 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { } yield { createSigningBasketResponseJson(signingBasket) } + } { created => + // Location of the created resource (IG 8.1, Mandatory), under the path the request came in on. + List("Location" -> s"${req.callContext.url.takeWhile(_ != '?').stripSuffix("/")}/${created.basketId}") } } 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 a8c6ab2ead..2fc462e026 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -641,6 +641,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ CustomResponseHeaders(List( (ResponseHeader.`ASPSP-SCA-Approach`, aspspScaApproach) )) + // The approach is fixed per instance, so the standard requires the header ("must be contained, if + // the SCA Approach is already fixed") on the two calls that create a signing basket resource. + case Some(cc) if cc.url.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) && cc.verb == "POST" && + (cc.url.endsWith("/signing-baskets") || (cc.url.contains("/signing-baskets/") && cc.url.endsWith("/authorisations"))) => + CustomResponseHeaders(List( + (ResponseHeader.`ASPSP-SCA-Approach`, aspspScaApproach) + )) case _ => CustomResponseHeaders(Nil) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 09c138b3af..7e23e9105f 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -580,6 +580,25 @@ object Http4sRequestAttributes { } } + /** + * executeFutureCreated for a response that has to carry headers derived from the created resource + * (Berlin Group's `Location`, for one), which only exist once the handler has produced it. + */ + def executeFutureCreatedWithHeaders[A](req: Request[IO])(f: => Future[A])(headersFor: A => List[(String, String)])(implicit formats: Formats): IO[Response[IO]] = { + implicit val cc: CallContext = req.callContext + RequestScopeConnection.fromFuture(f).attempt.flatMap { + case Right(result) => + val jsonString = renderJson(result) + Created(jsonString, jsonContentType) + .map(withCallContextHeaders) + .map(response => headersFor(result).foldLeft(response) { + case (r, (name, value)) => r.putHeaders(Header.Raw(CIString(name), value)) + }) + .flatTap(recordMetric(result, _)) + case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) + } + } + /** * Execute Future-based business logic that returns a (result, statusCode) pair, rendering the * result JSON with the caller-supplied HTTP status. Converts errors via ErrorResponseConverter. diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 635982bcc8..156c524134 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -449,6 +449,9 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { response.code should equal(201) val authorisationId = (response.body \ "authorisationId").extract[String] (response.body \ "scaStatus").extract[String] should equal("received") + withClue("ASPSP-SCA-Approach is sent when the authorisation resource is created (IG §7.1): ") { + Option(response.headers.getOrElse(fail("the response has no headers")).get("ASPSP-SCA-Approach")) should not be empty + } (response.body \ "_links" \ "scaStatus" \ "href").extract[String] should endWith(s"/signing-baskets/$basketId/authorisations/$authorisationId") } From f7378d8d32fd62444d4afd459059fc7bf0e36c17 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:36:11 +0200 Subject: [PATCH 08/40] feat: record who created a signing basket and add conditional status changes A signing basket carried only its id and status, so any authenticated caller holding the id could read, cancel or authorise it. Store the consumer that created it, the PSU it is for once known, and the creation time, and make the owner readable through SigningBasketTrait (absent on baskets created before this change). Add the provider operations the following changes build on: transitionSigningBasketStatus is one conditional UPDATE, so callers racing for different transitions out of the same status have exactly one winner; bindSigningBasketPsu binds the PSU once. Creation writes the basket and its members together and removes what it wrote if any step fails. The existing columns are untouched; Schemifier adds the new ones. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 5 + .../MappedSigningBasketProvider.scala | 118 ++++++++++++------ .../code/signingbaskets/SigningBasket.scala | 21 +++- .../MappedSigningBasketProviderTest.scala | 110 ++++++++++++++++ .../commons/model/CommonModelTrait.scala | 4 + 5 files changed, 220 insertions(+), 38 deletions(-) create mode 100644 obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index a3dec2fd32..a7f52b40ba 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -113,10 +113,15 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { _ <- booleanToFuture(failMsg, cc = callContext) { idLists.nonEmpty && idLists.forall(ids => ids.nonEmpty && ids.distinct.size == ids.size) } + // The basket belongs to the TPP that creates it; nothing else identifies who may address it later. + consumerId <- Future.successful(cc.consumer.map(_.consumerId.get)) + .map(unboxFullOrFail(_, callContext, AuthenticatedUserIsRequired, 401)) signingBasket <- Future { SigningBasketX.signingBasketProvider.vend.createSigningBasket( postJson.paymentIds, postJson.consentIds, + consumerId, + None ) }.map(connectorEmptyResponse(_, callContext)) } yield { diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index d8bd342f55..9642939e95 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -32,77 +32,121 @@ import code.util.MappedUUID import com.openbankproject.commons.model.{SigningBasketConsentTrait, SigningBasketContent, SigningBasketPaymentTrait, SigningBasketTrait} import net.liftweb.common.Box import net.liftweb.common.Box.tryo +import net.liftweb.db.DB import net.liftweb.mapper._ +import net.liftweb.util.DefaultConnectionIdentifier object MappedSigningBasketProvider extends SigningBasketProvider { def getSigningBaskets(): List[SigningBasketTrait] = { MappedSigningBasket.findAll() } - override def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] = { - val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)) - val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId)).map(_.paymentId) match { + private def membersOf(basketId: String): (Option[List[String]], Option[List[String]]) = { + val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, basketId)).map(_.paymentId) match { case Nil => None - case head :: tail => Some(head :: tail) + case members => Some(members) } - val consents = MappedSigningBasketConsent.findAll(By(MappedSigningBasketConsent.BasketId, entityId)).map(_.consentId) match { + val consents = MappedSigningBasketConsent.findAll(By(MappedSigningBasketConsent.BasketId, basketId)).map(_.consentId) match { case Nil => None - case head :: tail => Some(head :: tail) + case members => Some(members) } - basket.map( i => SigningBasketContent(basket = i, payments = payments, consents = consents)) + (payments, consents) + } + + override def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] = { + val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)) + val (payments, consents) = membersOf(entityId) + basket.map(i => SigningBasketContent(basket = i, payments = payments, consents = consents)) } + override def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] = { val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)).map(_.Status(status).saveMe) - val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId)).map(_.paymentId) match { - case Nil => None - case head :: tail => Some(head :: tail) - } - val consents = MappedSigningBasketConsent.findAll(By(MappedSigningBasketConsent.BasketId, entityId)).map(_.consentId) match { - case Nil => None - case head :: tail => Some(head :: tail) + val (payments, consents) = membersOf(entityId) + basket.map(i => SigningBasketContent(basket = i, payments = payments, consents = consents)) + } + + override def deleteSigningBasket(id: String): Box[Boolean] = { + MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map { + _.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save } - basket.map( i => SigningBasketContent(basket = i, payments = payments, consents = consents)) } override def createSigningBasket(paymentIds: Option[List[String]], - consentIds: Option[List[String]] + consentIds: Option[List[String]], + consumerId: String, + psuUserId: Option[String] ): Box[SigningBasketTrait] = { - tryo { - val entity = MappedSigningBasket.create - entity.Status(ConstantsBG.SigningBasketsStatus.RCVD.toString) - - if (entity.validate.isEmpty) { - entity.saveMe() - } else { - throw new Error(entity.validate.map(_.msg.toString()).mkString(";")) + // The basket and every member row are written inside one DB.use. Inside an HTTP request the + // connection is the request's own, whose rollback is not ours to call, so a failure part way is + // also undone by hand: nothing of a basket that was not fully created is left behind. + var created: Option[MappedSigningBasket] = None + val result = tryo { + DB.use(DefaultConnectionIdentifier) { _ => + val entity = MappedSigningBasket.create + .Status(ConstantsBG.SigningBasketsStatus.RCVD.toString) + .ConsumerId(consumerId) + .PsuUserId(psuUserId.getOrElse("")) + if (entity.validate.isEmpty) { + entity.saveMe() + } else { + throw new Error(entity.validate.map(_.msg.toString()).mkString(";")) + } + created = Some(entity) + paymentIds.getOrElse(Nil).foreach { paymentId => + MappedSigningBasketPayment.create.BasketId(entity.basketId).PaymentId(paymentId).saveMe() + } + consentIds.getOrElse(Nil).foreach { consentId => + MappedSigningBasketConsent.create.BasketId(entity.basketId).ConsentId(consentId).saveMe() + } + entity: SigningBasketTrait } - paymentIds.getOrElse(Nil).map { paymentId => - MappedSigningBasketPayment.create.BasketId(entity.basketId).PaymentId(paymentId).saveMe() - } - consentIds.getOrElse(Nil).map { consentId => - MappedSigningBasketConsent.create.BasketId(entity.basketId).ConsentId(consentId).saveMe() + } + if (result.isEmpty) created.foreach { basket => + tryo { + MappedSigningBasketPayment.bulkDelete_!!(By(MappedSigningBasketPayment.BasketId, basket.basketId)) + MappedSigningBasketConsent.bulkDelete_!!(By(MappedSigningBasketConsent.BasketId, basket.basketId)) + basket.delete_! } - entity } + result } - override def deleteSigningBasket(id: String): Box[Boolean] = { - MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map { - _.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save + override def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] = + tryo { + DB.runUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} " + + s"SET ${MappedSigningBasket.Status._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?", + List(to, basketId, from)) == 1 + } + + override def bindSigningBasketPsu(basketId: String, psuUserId: String): Box[Boolean] = + tryo { + val bound = DB.runUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} " + + s"SET ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? " + + s"AND (${MappedSigningBasket.PsuUserId._dbColumnNameLC} IS NULL OR ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = '')", + List(psuUserId, basketId)) == 1 + // Not bound by this call: that is only a success if the basket was already bound to this PSU. + bound || MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basketId)).exists(_.psuUserId.contains(psuUserId)) } - } } -class MappedSigningBasket extends SigningBasketTrait with LongKeyedMapper[MappedSigningBasket] with IdPK { +class MappedSigningBasket extends SigningBasketTrait with LongKeyedMapper[MappedSigningBasket] with IdPK with CreatedUpdated { override def getSingleton = MappedSigningBasket object BasketId extends MappedUUID(this) object Status extends MappedString(this, 50) - - + // The consumer (TPP) that created the basket. Empty, or null, on a basket created before this was recorded. + object ConsumerId extends MappedString(this, 255) + // The PSU the basket is for, once known (named on creation, or bound when an authorisation starts). + object PsuUserId extends MappedString(this, 255) override def basketId: String = BasketId.get override def status: String = Status.get + override def consumerId: Option[String] = Option(ConsumerId.get).map(_.trim).filter(_.nonEmpty) + override def psuUserId: Option[String] = Option(PsuUserId.get).map(_.trim).filter(_.nonEmpty) } diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index 8400fc113a..0f5e947906 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -43,12 +43,31 @@ trait SigningBasketProvider extends MdcLoggable { def getSigningBaskets(): List[SigningBasketTrait] def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] + // Unconditional writers, being replaced by transitionSigningBasketStatus. def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] + def deleteSigningBasket(id: String): Box[Boolean] + /** + * Creates the basket and its members together, owned by the consumer that creates it. A failure + * part way leaves nothing behind. `psuUserId` is the PSU the request already names, if any. + */ def createSigningBasket(paymentIds: Option[List[String]], consentIds: Option[List[String]], + consumerId: String, + psuUserId: Option[String] ): Box[SigningBasketTrait] - def deleteSigningBasket(id: String): Box[Boolean] + /** + * Moves a basket from one status to another only if it still has the status the caller read. + * One conditional update, so two callers racing for the same transition have exactly one winner. + * Returns whether this call made the move. + */ + def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] + + /** + * Binds the PSU to the basket if none is bound yet. Returns whether the basket is now bound to + * this PSU, which is also true when it already was. + */ + def bindSigningBasketPsu(basketId: String, psuUserId: String): Box[Boolean] } diff --git a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala new file mode 100644 index 0000000000..276b6ee537 --- /dev/null +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -0,0 +1,110 @@ +/** +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.signingbaskets + +import code.setup.ServerSetup +import net.liftweb.mapper.By + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class MappedSigningBasketProviderTest extends ServerSetup { + + private val provider = MappedSigningBasketProvider + + private def newBasket(consumerId: String = "consumer-1", psuUserId: Option[String] = None) = + provider.createSigningBasket(Some(List("payment-1", "payment-2")), Some(List("consent-1")), consumerId, psuUserId) + .openOrThrowException("the basket must be created") + + feature("a signing basket records who created it") { + scenario("the creating consumer, the named PSU, the creation time and the members are stored") { + val basket = newBasket("consumer-a", Some("psu-a")) + val stored = provider.getSigningBasketByBasketId(basket.basketId).openOrThrowException("stored") + stored.basket.status should equal("RCVD") + stored.basket.consumerId should equal(Some("consumer-a")) + stored.basket.psuUserId should equal(Some("psu-a")) + stored.payments should equal(Some(List("payment-1", "payment-2"))) + stored.consents should equal(Some(List("consent-1"))) + MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basket.basketId)).map(_.createdAt.get.getTime > 0) should equal(net.liftweb.common.Full(true)) + } + + scenario("a basket created without a PSU has none, and a row with no consumer reads as unowned") { + val basket = newBasket() + provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.psuUserId) should equal(net.liftweb.common.Full(None)) + val legacy = MappedSigningBasket.create.Status("RCVD").saveMe() + provider.getSigningBasketByBasketId(legacy.basketId).map(_.basket.consumerId) should equal(net.liftweb.common.Full(None)) + } + } + + feature("a status transition happens only from the status the caller read") { + scenario("the first caller moves the basket and the next finds it already moved") { + val basket = newBasket() + provider.transitionSigningBasketStatus(basket.basketId, "RCVD", "CANC").openOrThrowException("x") should be(true) + provider.transitionSigningBasketStatus(basket.basketId, "RCVD", "ACTC").openOrThrowException("x") should be(false) + provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.status) should equal(net.liftweb.common.Full("CANC")) + } + + scenario("a transition from the wrong status changes nothing") { + val basket = newBasket() + provider.transitionSigningBasketStatus(basket.basketId, "ACTC", "CANC").openOrThrowException("x") should be(false) + provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.status) should equal(net.liftweb.common.Full("RCVD")) + } + + scenario("callers racing for different transitions out of RCVD have exactly one winner") { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 20).foreach { round => + val basket = newBasket() + val callers = (1 to 8).map { i => + val target = if (i % 2 == 0) "ACTC" else "CANC" + Future(provider.transitionSigningBasketStatus(basket.basketId, "RCVD", target).openOr(false) -> target) + } + val outcomes = Await.result(Future.sequence(callers), 60.seconds) + withClue(s"round $round: ") { + outcomes.count(_._1) should equal(1) + provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.status) should equal(net.liftweb.common.Full(outcomes.find(_._1).get._2)) + } + } + } + } + + feature("the PSU is bound once") { + scenario("the first PSU binds, the same PSU may bind again, another PSU may not") { + val basket = newBasket() + provider.bindSigningBasketPsu(basket.basketId, "psu-1").openOrThrowException("x") should be(true) + provider.bindSigningBasketPsu(basket.basketId, "psu-1").openOrThrowException("x") should be(true) + provider.bindSigningBasketPsu(basket.basketId, "psu-2").openOrThrowException("x") should be(false) + provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.psuUserId) should equal(net.liftweb.common.Full(Some("psu-1"))) + } + + scenario("a PSU named at creation cannot be replaced") { + val basket = newBasket(psuUserId = Some("psu-named")) + provider.bindSigningBasketPsu(basket.basketId, "psu-other").openOrThrowException("x") should be(false) + provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.psuUserId) should equal(net.liftweb.common.Full(Some("psu-named"))) + } + } +} diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala index 73e58c53b8..8a592ead2a 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala @@ -99,6 +99,10 @@ trait AccountApplication { trait SigningBasketTrait { def basketId: String def status: String + /** The consumer (TPP) that created the basket. None on a basket created before ownership was recorded. */ + def consumerId: Option[String] = None + /** The PSU the basket's SCA is for, once known. */ + def psuUserId: Option[String] = None } case class SigningBasketContent( basket: SigningBasketTrait, From d22e51b5ca53eaf8790e75682ce244721f73bb88 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:38:15 +0200 Subject: [PATCH 09/40] refactor: resolve the PSU-ID header next to resolveBerlinGroupPsu The consent authorisation endpoints turn the PSU-ID header into a user id before calling Consent.resolveBerlinGroupPsu, in a private helper of the AIS routes. The signing basket authorisation needs the same step. Move the helper into Consent so both use one implementation; the AIS routes keep their call sites. No behaviour change. --- .../api/berlin/group/v1_3/Http4sBGv13AIS.scala | 17 +---------------- .../main/scala/code/api/util/ConsentUtil.scala | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 16 deletions(-) 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 a961d1c0d9..ce1b315f68 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 @@ -533,23 +533,8 @@ object Http4sBGv13AIS extends MdcLoggable { } } - /** - * The PSU-ID request header, resolved to the user id it names. - * - * Berlin Group makes the header conditional rather than mandatory, so absent is a conforming - * answer and gives None -- the caller may be identifying the PSU some other way, which - * Consent.resolveBerlinGroupPsu works out. A value the ASPSP cannot resolve is a different matter - * and is refused with the code the standard reserves for exactly it: PSU_CREDENTIALS_INVALID, 401, - * "PSU-ID cannot be found by ASPSP". - */ private def resolvePsuIdHeader(cc: CallContext, callContext: Option[CallContext]): Future[Option[String]] = - Option(APIUtil.getRequestHeader(RequestHeader.`PSU-ID`, cc.requestHeaders)).map(_.trim).filter(_.nonEmpty) match { - case None => Future.successful(None) - case Some(psuId) => - Future(Consent.findPsuByPsuId(psuId)) map { psu => - Some(unboxFullOrFail(psu, callContext, UserNotFoundByProviderAndUsername, 401).userId) - } - } + Consent.resolvePsuIdHeader(cc, callContext) // ── POST /consents/CONSENTID/authorisations (3 body-guard variants) ───── lazy val startConsentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 92e057c7d4..c0da1c82e2 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -2359,6 +2359,24 @@ object Consent extends MdcLoggable { unusable.isEmpty } + /** + * The PSU-ID request header, resolved to the user id it names. + * + * Berlin Group makes the header conditional rather than mandatory, so absent is a conforming + * answer and gives None -- the caller may be identifying the PSU some other way, which + * resolveBerlinGroupPsu works out. A value the ASPSP cannot resolve is a different matter and is + * refused with the code the standard reserves for exactly it: PSU_CREDENTIALS_INVALID, 401, + * "PSU-ID cannot be found by ASPSP". + */ + def resolvePsuIdHeader(cc: CallContext, callContext: Option[CallContext]): Future[Option[String]] = + Option(APIUtil.getRequestHeader(RequestHeader.`PSU-ID`, cc.requestHeaders)).map(_.trim).filter(_.nonEmpty) match { + case None => Future.successful(None) + case Some(psuId) => + Future(findPsuByPsuId(psuId)) map { psu => + Some(APIUtil.unboxFullOrFail(psu, callContext, UserNotFoundByProviderAndUsername, 401).userId) + } + } + def createUKConsentJWT( user: Option[User], bankId: Option[String], From 7967eb55a198db44222be27eb1356c406573405e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:40:23 +0200 Subject: [PATCH 10/40] fix: let only the creating TPP address a signing basket Any authenticated caller who knew a basket id could read it, cancel it or start and answer its authorisation. Every operation that names a basket now goes through one guard, with permissions stated per operation: - reading, the status, and deleting: the creating TPP only; - the authorisation operations: the creating TPP, or the consumer declared in sca_front_end_consumer_ids for the PSU the basket is for. The rule is Consent.checkBerlinGroupConsentAccess, so a basket and a consent are held to the same standard. A basket created before ownership was recorded has no creating TPP and is refused to everyone; the consent prop that re-opens unowned consents does not apply to it. An unknown basket and one the caller may not address get the same answer, 403 reported as RESOURCE_UNKNOWN, so the endpoint does not reveal which ids exist (OBP-35051). An authorisation id the basket does not have, or one issued for another basket, is 404 RESOURCE_UNKNOWN (OBP-35052); the status read no longer answers 200 with a made-up scaStatus, and the wrong ConsentNotFound message is gone from baskets. An answer can no longer be routed to a basket the authorisation was not issued for. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 23 ++-- .../code/api/util/BerlinGroupError.scala | 4 + .../scala/code/api/util/ErrorMessages.scala | 2 + .../util/newstyle/SigningBasketNewStyle.scala | 91 ++++++++++++++- .../newstyle/SigningBasketAccessTest.scala | 105 ++++++++++++++++++ 5 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/util/newstyle/SigningBasketAccessTest.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index a7f52b40ba..cad4a13faa 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -39,6 +39,7 @@ import code.api.util.CustomJsonFormats import code.api.util.{ApiTag, CallContext, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.SigningBasketNewStyle +import code.api.util.newstyle.SigningBasketNewStyle.{AuthorisationOperation, CreatorOnly} import code.bankconnectors.Connector import code.signingbaskets.SigningBasketX import code.util.Helper.{MdcLoggable, booleanToFuture} @@ -182,6 +183,7 @@ The resource identifications of these transactions are contained in the payload val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) _ <- Future { SigningBasketX.signingBasketProvider.vend.deleteSigningBasket(basketid) }.map(connectorEmptyResponse(_, callContext)) @@ -216,9 +218,7 @@ Nevertheless, single transactions might be cancelled on an individual basis on t val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - basket <- Future { - SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) - }.map(connectorEmptyResponse(_, callContext)) + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) } yield { getSigningBasketResponseJson(basket) } @@ -251,6 +251,7 @@ Returns the content of an signing basket object.""", val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketid, AuthorisationOperation, callContext) (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketid, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) @@ -285,13 +286,10 @@ This function returns an array of hyperlinks to all generated authorisation sub- val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - _ <- Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId)) - .map(unboxFullOrFail(_, callContext, s"$ConsentNotFound ($basketId)", 403)) - (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketId, callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) + (challenge, _) <- SigningBasketNewStyle.getBasketAuthorisation(basketId, authorisationId, callContext) } yield { - val challengeStatus = challenges.filter(_.challengeId == authorisationId) - .flatMap(_.scaStatus).headOption.map(_.toString).getOrElse("None") - JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challengeStatus) + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challenge.scaStatus.map(_.toString).getOrElse("")) } } } @@ -321,9 +319,7 @@ This method returns the SCA status of a signing basket's authorisation sub-resou val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - basket <- Future { - SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) - }.map(connectorEmptyResponse(_, callContext)) + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) } yield { getSigningBasketStatusResponseJson(basket) } @@ -356,6 +352,7 @@ Returns the status of a signing basket object. val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) _ <- requireSupportedAuthorisationBody( cc.httpBody.getOrElse(""), answering = false, s"$InvalidJsonFormat The Json body should be empty, or the transactionAuthorisation body. ", callContext) @@ -442,6 +439,8 @@ This applies in the following scenarios: val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) + _ <- SigningBasketNewStyle.getBasketAuthorisation(basketId, authorisationId, callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " _ <- requireSupportedAuthorisationBody(cc.httpBody.getOrElse(""), answering = true, failMsg, callContext) updateBasketPsuDataJson <- NewStyle.function.tryons(failMsg, 400, callContext) { diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index b110a9e022..3f921bb12a 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -116,6 +116,10 @@ object BerlinGroupError { case "400" if message.contains("OBP-10034") => "PARAMETER_NOT_CONSISTENT" case "400" if message.contains("OBP-35050") => "SERVICE_INVALID" + // One answer for a signing basket that does not exist and one the caller may not address, so the + // endpoint is not a way to learn which basket ids exist. + case "403" if message.contains("OBP-35051") => "RESOURCE_UNKNOWN" + case "404" if message.contains("OBP-35052") => "RESOURCE_UNKNOWN" case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN" case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN" 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 7e688d8b02..282927a7e6 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -879,6 +879,8 @@ object ErrorMessages { val SigningBasketAuthorisationVariantNotSupported = "OBP-35050: This request body is not supported on a signing basket authorisation. " + "Send an empty body to start the authorisation, or {\"scaAuthenticationData\": ...} to answer it. " + "PSU authentication, authentication method selection and confirmation code requests are not available for signing baskets. " + val SigningBasketNotFound = "OBP-35051: Signing basket not found by BASKET_ID. " + val SigningBasketAuthorisationNotFound = "OBP-35052: Signing basket authorisation not found by AUTHORISATION_ID. " val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. " val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. " val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. " diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 046e8c6e9b..08f8739e4f 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -29,18 +29,103 @@ package code.api.util.newstyle import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} import code.api.util.CallContext -import code.api.util.ErrorMessages.{InvalidConnectorResponse, RegulatedEntityNotDeleted} +import code.api.util.Consent +import code.api.util.ErrorMessages.{InvalidConnectorResponse, RegulatedEntityNotDeleted, SigningBasketAuthorisationNotFound, SigningBasketNotFound} import code.bankconnectors.Connector import code.signingbaskets.SigningBasketX -import com.openbankproject.commons.model.TransactionRequestId +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.model.enums.ChallengeType +import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent, TransactionRequestId} import net.liftweb.common.{Box, Empty} import scala.concurrent.Future -object SigningBasketNewStyle { +object SigningBasketNewStyle extends MdcLoggable { import com.openbankproject.commons.ExecutionContext.Implicits.global + /** + * What a caller is addressing a basket for. + * + * A signing basket belongs to the TPP that created it (Implementation Guidelines 4.11: "the same + * TPP"). The one exception is the ASPSP's own SCA front end, which under Redirect drives the + * authorisation sub-resource because that is where the PSU authenticates. It is therefore allowed on + * the authorisation operations and nowhere else: not on reading, listing the status of, or deleting + * the basket. + */ + sealed trait BasketAccess + /** Read, status, delete: the creating TPP only. */ + case object CreatorOnly extends BasketAccess + /** The authorisation sub-resource: the creating TPP, or the declared SCA front end for the right PSU. */ + case object AuthorisationOperation extends BasketAccess + + /** + * Decide whether a caller may address a basket, returning the reason to refuse or None. + * + * Built on Consent.checkBerlinGroupConsentAccess, which is the same question for a consent: a PSU + * already bound must be the caller's PSU, and the Consumer that lodged the resource is the party it + * belongs to. A basket created before ownership was recorded has no Consumer and so belongs to + * nobody. The consent rule's prop that re-opens such consents is deliberately not honoured here: + * these baskets are quarantined, and an operator who needs one back assigns it explicitly. + */ + def accessRefusal(basketConsumerId: Option[String], + basketPsuUserId: Option[String], + callerConsumerId: Option[String], + callerPsuUserId: Option[String], + callerIsScaFrontEnd: Boolean, + access: BasketAccess): Option[String] = + basketConsumerId.flatMap(Consent.present) match { + case None => Some("The basket records no Consumer that created it") + case Some(owner) => + Consent.checkBerlinGroupConsentAccess( + basketPsuUserId.getOrElse(""), owner, + callerPsuUserId, callerConsumerId, + callerIsScaFrontEnd = access == AuthorisationOperation && callerIsScaFrontEnd) + } + + /** + * The basket, if the caller may address it for this operation. + * + * A basket that does not exist and one the caller may not address get the same answer (403, + * reported as RESOURCE_UNKNOWN), so the endpoint is not a way to learn which basket ids exist. The + * reason is logged. + */ + def getOwnBasket(basketId: String, + access: BasketAccess, + callContext: Option[CallContext]): OBPReturnType[SigningBasketContent] = Future { + val callerConsumerId = callContext.flatMap(_.consumer.map(_.consumerId.get)) + val refusal: Either[String, SigningBasketContent] = + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) match { + case net.liftweb.common.Full(content) => + accessRefusal( + content.basket.consumerId, content.basket.psuUserId, + callerConsumerId, callContext.flatMap(Consent.genuinePsu(_).map(_.userId)), + Consent.isScaFrontEnd(callerConsumerId), access) match { + case Some(reason) => Left(reason) + case None => Right(content) + } + case _ => Left("There is no such basket") + } + refusal.left.foreach(reason => logger.info(s"A signing basket was refused to its caller: $reason. Reported as $SigningBasketNotFound")) + refusal + } map { + case Right(content) => (content, callContext) + case Left(_) => unboxFullOrFail(Empty: Box[(SigningBasketContent, Option[CallContext])], callContext, SigningBasketNotFound, 403) + } + + /** + * An authorisation of this basket, by id. One issued for another basket, or for something that is + * not a signing basket, is not found: the connector's challenge lookup goes by challenge id alone. + */ + def getBasketAuthorisation(basketId: String, + authorisationId: String, + callContext: Option[CallContext]): OBPReturnType[ChallengeTrait] = + Connector.connector.vend.getChallenge(authorisationId, callContext) map { case (challenge, cc) => + val ofThisBasket = challenge.toOption.filter(c => + c.basketId.contains(basketId) && c.challengeType == ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE.toString) + (unboxFullOrFail(Box(ofThisBasket), callContext, SigningBasketAuthorisationNotFound, 404), cc) + } + def checkSigningBasketPayments(basketId: String, callContext: Option[CallContext] ): OBPReturnType[Boolean] = { diff --git a/obp-api/src/test/scala/code/api/util/newstyle/SigningBasketAccessTest.scala b/obp-api/src/test/scala/code/api/util/newstyle/SigningBasketAccessTest.scala new file mode 100644 index 0000000000..21c2be58ef --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/newstyle/SigningBasketAccessTest.scala @@ -0,0 +1,105 @@ +/** +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.newstyle + +import code.api.util.newstyle.SigningBasketNewStyle.{AuthorisationOperation, CreatorOnly, accessRefusal} +import code.setup.ServerSetup + +/** Who may address a signing basket, as a pure rule: no request, no database. */ +class SigningBasketAccessTest extends ServerSetup { + + private val tpp = Some("tpp-1") + private val otherTpp = Some("tpp-2") + private val psu = Some("psu-1") + private val otherPsu = Some("psu-2") + private val frontEnd = Some("portal") + + feature("the creating TPP addresses its own basket") { + scenario("on every operation, with or without a PSU session") { + List(CreatorOnly, AuthorisationOperation).foreach { access => + accessRefusal(tpp, None, tpp, None, callerIsScaFrontEnd = false, access) should equal(None) + accessRefusal(tpp, psu, tpp, None, callerIsScaFrontEnd = false, access) should equal(None) + accessRefusal(tpp, psu, tpp, psu, callerIsScaFrontEnd = false, access) should equal(None) + } + } + + scenario("but not when the PSU in its session is not the PSU the basket is for") { + List(CreatorOnly, AuthorisationOperation).foreach { access => + accessRefusal(tpp, psu, tpp, otherPsu, callerIsScaFrontEnd = false, access) should not equal None + } + } + } + + feature("another TPP addresses nothing, whoever the PSU is") { + scenario("on every operation") { + List(CreatorOnly, AuthorisationOperation).foreach { access => + accessRefusal(tpp, None, otherTpp, None, callerIsScaFrontEnd = false, access) should not equal None + accessRefusal(tpp, psu, otherTpp, psu, callerIsScaFrontEnd = false, access) should not equal None + } + } + } + + feature("the SCA front end acts on the authorisation and on nothing else") { + scenario("it may drive the authorisation of a basket with no PSU yet, or for the PSU it names") { + accessRefusal(tpp, None, frontEnd, None, callerIsScaFrontEnd = true, AuthorisationOperation) should equal(None) + accessRefusal(tpp, None, frontEnd, psu, callerIsScaFrontEnd = true, AuthorisationOperation) should equal(None) + accessRefusal(tpp, psu, frontEnd, psu, callerIsScaFrontEnd = true, AuthorisationOperation) should equal(None) + } + + scenario("it may not drive the authorisation of a basket bound to another PSU") { + accessRefusal(tpp, psu, frontEnd, otherPsu, callerIsScaFrontEnd = true, AuthorisationOperation) should not equal None + } + + scenario("it may not read, check the status of, or delete the basket") { + accessRefusal(tpp, None, frontEnd, None, callerIsScaFrontEnd = true, CreatorOnly) should not equal None + accessRefusal(tpp, psu, frontEnd, psu, callerIsScaFrontEnd = true, CreatorOnly) should not equal None + } + + scenario("a consumer that is not declared as the front end gets none of this") { + accessRefusal(tpp, None, frontEnd, None, callerIsScaFrontEnd = false, AuthorisationOperation) should not equal None + } + } + + feature("a PSU's session under a second TPP is not that TPP's mandate") { + scenario("the same PSU through another consumer is refused") { + accessRefusal(tpp, psu, otherTpp, psu, callerIsScaFrontEnd = false, CreatorOnly) should not equal None + accessRefusal(tpp, psu, otherTpp, psu, callerIsScaFrontEnd = false, AuthorisationOperation) should not equal None + } + } + + feature("a basket created before ownership was recorded belongs to nobody") { + scenario("it is refused to every caller, the SCA front end included") { + List(None, Some(""), Some(" ")).foreach { noOwner => + List(CreatorOnly, AuthorisationOperation).foreach { access => + accessRefusal(noOwner, None, tpp, None, callerIsScaFrontEnd = false, access) should not equal None + accessRefusal(noOwner, None, frontEnd, psu, callerIsScaFrontEnd = true, access) should not equal None + } + } + } + } +} From e3eab9e1805894aee7dcba2d280f288e58cb6c7e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:42:44 +0200 Subject: [PATCH 11/40] fix: mint a signing basket authorisation for the PSU, not the calling TPP POST authorisations created the challenge for the session's user. For a client-credentials TPP that is its own pseudo-user, so the one-time password was sent to the TPP and never reached the PSU. Resolve the PSU as the consent authorisation does, with Consent.resolveBerlinGroupPsu: the PSU the basket already names, a genuine PSU in the session, then the PSU-ID header. Bind that PSU to the basket and mint the challenge for them. A header that contradicts the bound PSU is refused like any other attempt to address the basket; with no PSU identifiable the call is refused with PSU_CREDENTIALS_INVALID. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 6 ++-- .../util/newstyle/SigningBasketNewStyle.scala | 32 +++++++++++++++++-- .../v1_3/SigningBasketServiceSBSApiTest.scala | 30 +++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index cad4a13faa..07d953c76d 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -352,12 +352,14 @@ Returns the status of a signing basket object. val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - _ <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) _ <- requireSupportedAuthorisationBody( cc.httpBody.getOrElse(""), answering = false, s"$InvalidJsonFormat The Json body should be empty, or the transactionAuthorisation body. ", callContext) + // Whose challenge this is, which is also where the OTP goes: the PSU, not the calling TPP. + psuUserId <- SigningBasketNewStyle.bindAuthorisingPsu(basket, cc, callContext) (challenges, _) <- NewStyle.function.createChallengesC3( - List(cc.user.map(_.userId).openOr("")), + List(psuUserId), ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, getSuggestedDefaultScaMethod(), diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 08f8739e4f..849c0daaae 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -30,10 +30,10 @@ package code.api.util.newstyle import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} import code.api.util.CallContext import code.api.util.Consent -import code.api.util.ErrorMessages.{InvalidConnectorResponse, RegulatedEntityNotDeleted, SigningBasketAuthorisationNotFound, SigningBasketNotFound} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, InvalidConnectorResponse, RegulatedEntityNotDeleted, SigningBasketAuthorisationNotFound, SigningBasketNotFound} import code.bankconnectors.Connector import code.signingbaskets.SigningBasketX -import code.util.Helper.MdcLoggable +import code.util.Helper.{MdcLoggable, booleanToFuture} import com.openbankproject.commons.model.enums.ChallengeType import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent, TransactionRequestId} import net.liftweb.common.{Box, Empty} @@ -113,6 +113,34 @@ object SigningBasketNewStyle extends MdcLoggable { case Left(_) => unboxFullOrFail(Empty: Box[(SigningBasketContent, Option[CallContext])], callContext, SigningBasketNotFound, 403) } + /** + * The PSU an authorisation on this basket is for, bound to the basket. + * + * It decides whose challenge this is, which is also where the one-time password is sent, so it is + * not read off the session: under Berlin Group the caller is the TPP, and a client-credentials TPP + * resolves to a pseudo-user of its own. The order is Consent.resolveBerlinGroupPsu's: the PSU the + * basket already names, a genuine PSU in the session (Redirect), then the PSU-ID header (Embedded). + * A header that contradicts the basket's PSU gets the same answer as any other refusal to address + * the basket; with none of the three there is nobody to authorise for, which is the standard's + * PSU_CREDENTIALS_INVALID (401). + */ + def bindAuthorisingPsu(basket: SigningBasketContent, + cc: CallContext, + callContext: Option[CallContext]): Future[String] = + for { + headerPsuUserId <- Consent.resolvePsuIdHeader(cc, callContext) + psuUserId <- Consent.resolveBerlinGroupPsu( + basket.basket.psuUserId.getOrElse(""), Consent.genuinePsu(cc).map(_.userId), headerPsuUserId) match { + case Right(userId) => Future.successful(userId) + case Left(reason) => + val (failMsg, failCode) = + if (reason == ConsentDoesNotMatchUser) (SigningBasketNotFound, 403) else (reason, 401) + booleanToFuture(failMsg = failMsg, failCode = failCode, cc = callContext)(false).map(_ => "") + } + bound <- Future(SigningBasketX.signingBasketProvider.vend.bindSigningBasketPsu(basket.basket.basketId, psuUserId)) + _ <- booleanToFuture(failMsg = SigningBasketNotFound, failCode = 403, cc = callContext)(bound.openOr(false)) + } yield psuUserId + /** * An authorisation of this basket, by id. One issued for another basket, or for something that is * not a signing basket, is not found: the connector's challenge lookup goes by challenge id alone. diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 156c524134..3a459dff21 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -675,6 +675,36 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } + // ───────────────────────── whose challenge: the PSU, not the calling TPP ───────────────────────── + + feature("BG v1.3 signing baskets - an authorisation is minted for the PSU, which is where the one-time password goes") { + scenario("S1: a client-credentials TPP naming the PSU in PSU-ID gets a challenge for that PSU, and the basket binds to them", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + val response = makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser1.name))) + response.code should equal(201) + val authorisationId = (response.body \ "authorisationId").extract[String] + Challenges.ChallengeProvider.vend.getChallenge(authorisationId).openOrThrowException("challenge").expectedUserId should equal(resourceUser1.userId) + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId).map(_.basket.psuUserId) should equal(net.liftweb.common.Full(Some(resourceUser1.userId))) + } + + scenario("S1: a client-credentials TPP that names nobody gets no challenge (L11597, IG §14.11 PSU_CREDENTIALS_INVALID)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + expectRefusal(startAuthorisation(basketId, as = clientCredentialsSession), 401, "PSU_CREDENTIALS_INVALID", "no PSU anywhere") + expectRefusal(makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", "nobody-by-this-name"))), 401, "PSU_CREDENTIALS_INVALID", "an unknown PSU-ID") + storedChallengeCount(basketId) should equal(0) + } + + scenario("S1: once a PSU is bound, a PSU-ID naming someone else is refused like any other refusal to address the basket", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser1.name))).code should equal(201) + val challengesBefore = storedChallengeCount(basketId) + expectRefusal(makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser2.name))), 403, "RESOURCE_UNKNOWN", "another PSU") + storedChallengeCount(basketId) should equal(challengesBefore) + } + } + // ───────────────────────── members: S5, D8 ───────────────────────── feature("BG v1.3 signing baskets - a basket only takes members the caller may authorise") { From baf710842f3e9e052e590d33bd87707a736bdfb2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:46:48 +0200 Subject: [PATCH 12/40] fix: check the answer to a signing basket authorisation before changing anything The PUT started booking payments and saved the basket as ACTC before it looked at the result of checking the answer, and decided what to do from a re-read of the challenge rather than from that result. Presenting a challenge that had already been finalised, with a wrong answer, against another basket made that basket ACTC and marked its payments completed while the response was an error. The connector's challenge check goes by challenge id alone, so nothing tied the challenge to the basket. Rework the order: 1. the caller may address this basket and this authorisation; 2. the request can succeed at all: the instance enables it, the basket holds no consent, the basket is RCVD, the authorisation is not already finalised or failed, every payment exists and is not already booked; 3. the answer is checked as the PSU the challenge was minted for, so a TPP relaying the PSU's one-time password works; 4. the basket is claimed with one conditional update, RCVD to an internal AUTHORISING state that is reported as RCVD; losing it is a 409, so a delete racing the final answer has one winner; 5. only then do the payments change and the basket becomes ACTC. A wrong, expired or used-up one-time password is now 401 PSU_CREDENTIALS_INVALID, the standard's code for an incorrect OTP, and an authorisation answered twice is 409 STATUS_INVALID. Neither reached a code from the standard's list before. Add signing_basket_authorisation_enabled, default false. Answering an authorisation still starts the booking of the payments without waiting for its outcome, and a basket can report itself authorised while a payment was not booked; until that is replaced, a basket is not authorised on an instance that has not opted in (403 SERVICE_BLOCKED). A basket that holds a consent is refused with 400 SERVICE_INVALID, because nothing activates the consent. --- .../resources/props/sample.props.template | 9 +- .../code/api/berlin/group/ConstantsBG.scala | 10 ++ .../v1_3/Http4sBGv13SigningBaskets.scala | 125 +++++++++++------- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 6 +- .../code/api/util/BerlinGroupError.scala | 11 ++ .../scala/code/api/util/ErrorMessages.scala | 6 + .../util/newstyle/SigningBasketNewStyle.scala | 26 +--- .../v1_3/SigningBasketServiceSBSApiTest.scala | 48 ++++++- 8 files changed, 162 insertions(+), 79 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 5dcd849c40..821b537311 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1672,9 +1672,16 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER ## Berlin Group Create Consent Frequency per Day Upper Limit #berlin_group_frequency_per_day_upper_limit = 4 -## Berlin Group Create Consent ASPSP-SCA-Approach response header value +## Berlin Group ASPSP-SCA-Approach response header value (create consent, create signing basket and its authorisations) #berlin_group_aspsp_sca_approach = redirect +# Whether a Berlin Group signing basket may be authorised (PUT /signing-baskets/{basketId}/authorisations/{authorisationId}). +# Default false: the call answers 403 SERVICE_BLOCKED. Answering the authorisation of a basket starts the +# booking of its payments without waiting for the result, so a basket can report itself authorised while a +# payment was not booked. Turn this on only once that has been replaced. Creating, reading, starting an +# authorisation on and deleting baskets are not affected. +#signing_basket_authorisation_enabled = false + # Support multiple brands on one instance. Note this needs checking on a clustered environment #brands_enabled=false diff --git a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala index 6802d93566..cc048cc33b 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala @@ -47,5 +47,15 @@ object ConstantsBG { // 4) CANC (Cancelled) and // 5) RJCT (Rejected) are supported for signing baskets. val RCVD, PATC, ACTC, CANC, RJCT = Value + + /** + * Stored between the moment a correct answer claims the basket and the moment its members have + * been dealt with. It is never reported: to a TPP a basket in this state is still RCVD, since the + * authorisation has not completed from its point of view. + */ + val AUTHORISING_INTERNAL = "AUTHORISING" + + def external(storedStatus: String): String = + if (storedStatus == AUTHORISING_INTERNAL) RCVD.toString else storedStatus } } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 07d953c76d..a376f9c255 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -32,7 +32,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect._ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getPropsAsBoolValue, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats @@ -48,7 +48,7 @@ import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.enums.TransactionRequestStatus.{COMPLETED, REJECTED} import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} import com.openbankproject.commons.model.{ChallengeTrait, TransactionRequestId} -import net.liftweb.common.Empty +import net.liftweb.common.{Box, Empty, Failure, Full} import com.openbankproject.commons.util.json import org.json4s.Formats import org.http4s._ @@ -434,21 +434,76 @@ This applies in the following scenarios: http4sPartialFunction = Some(startSigningBasketAuthorisation) ) + /** + * How a failed challenge validation is answered. A wrong, expired or used-up one-time password is + * the standard's PSU_CREDENTIALS_INVALID (401, "the password/OTP is incorrect"); an authorisation + * answered a second time, concurrently or later, is a conflict. + */ + private def challengeFailure(message: String): (String, Int) = + if (message.contains("Challenge already answered")) (SigningBasketStatusInvalid, 409) + else if (message.contains("OBP-40016") || message.contains("OBP-20211") || message.contains("OBP-40014")) (message, 401) + else (message, 400) + + /** + * Starts the booking of each payment and marks it completed. Neither is awaited and neither outcome + * is read, which is the defect this leaves in place: replacing it is the payment execution work and + * is not part of this change. What this change does guarantee is who gets here -- only the basket's + * owner, with a correct answer to an authorisation of this basket, and only once, since the basket is + * claimed before this runs. That is also why `signing_basket_authorisation_enabled` is off by default. + */ + private def startPaymentExecution(paymentIds: List[String], callContext: Option[CallContext]): Unit = + paymentIds.foreach { paymentId => + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(paymentId), COMPLETED.toString, callContext) + Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext).map { t => + Connector.connector.vend.makePaymentV400(t._1, None, callContext) + } + } + // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + // + // Order matters, and nothing may be changed until the answer has been checked: + // 1. who the caller is and whether this is their basket and their authorisation; + // 2. whether the request can succeed at all (instance setting, members, basket state, challenge state); + // 3. the answer, checked as the PSU the challenge was minted for; + // 4. the basket is claimed with one conditional update, so the final answer and a delete racing it + // have exactly one winner; + // 5. only then do members change, and the basket is completed. val updateSigningBasketPsuData: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" / authorisationId => EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) + val provider = SigningBasketX.signingBasketProvider.vend for { _ <- passesPsd2Pisp(callContext) - _ <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) - _ <- SigningBasketNewStyle.getBasketAuthorisation(basketId, authorisationId, callContext) + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) + (startedChallenge, _) <- SigningBasketNewStyle.getBasketAuthorisation(basketId, authorisationId, callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " _ <- requireSupportedAuthorisationBody(cc.httpBody.getOrElse(""), answering = true, failMsg, callContext) updateBasketPsuDataJson <- NewStyle.function.tryons(failMsg, 400, callContext) { json.parse(cc.httpBody.getOrElse("")).extract[UpdatePaymentPsuDataJson] } - _ <- SigningBasketNewStyle.checkSigningBasketPayments(basketId, callContext) + _ <- booleanToFuture(SigningBasketAuthorisationDisabled, failCode = 403, cc = callContext) { + getPropsAsBoolValue("signing_basket_authorisation_enabled", defaultValue = false) + } + _ <- booleanToFuture(SigningBasketConsentNotSupported, failCode = 400, cc = callContext) { + basket.consents.forall(_.isEmpty) + } + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + basket.basket.status == ConstantsBG.SigningBasketsStatus.RCVD.toString + } + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + !startedChallenge.scaStatus.exists(status => + status == StrongCustomerAuthenticationStatus.finalised || status == StrongCustomerAuthenticationStatus.failed) + } + paymentIds = basket.payments.getOrElse(Nil) + members <- Future(paymentIds.map(id => id -> Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(id), callContext))) + _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext)(members.forall(_._2.isDefined)) + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + !members.exists(_._2.exists(_._1.status == COMPLETED.toString)) + } + // The answer is the PSU's, relayed by the TPP under Embedded, so it is checked against the + // challenge's own PSU rather than the principal on the token. + (psu, _) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, @@ -457,51 +512,25 @@ This applies in the following scenarios: authorisationId, updateBasketPsuDataJson.scaAuthenticationData, SuppliedAnswerType.PLAIN_TEXT_VALUE, - callContext + callContext.map(_.copy(user = Full(psu))) ) - (challenge, updatedCC) <- NewStyle.function.getChallenge(authorisationId, callContext) - _ <- challenge.scaStatus match { - case Some(status) if status.toString == StrongCustomerAuthenticationStatus.finalised.toString => - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - val existAll = - basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).isDefined))) - val alreadyCompleted: List[String] = - basket.flatMap(_.payments).getOrElse(Nil).filter { i => - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC) - .exists(_._1.status == COMPLETED.toString) - } - if (alreadyCompleted.nonEmpty) { - unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${alreadyCompleted.mkString(",")}] are already completed") - } else if (existAll.getOrElse(false)) { - basket.map { i => - i.payments.map(_.map { i => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), COMPLETED.toString, updatedCC) - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).map { t => - Connector.connector.vend.makePaymentV400(t._1, None, updatedCC) - } - }) - } - SigningBasketX.signingBasketProvider.vend.saveSigningBasketStatus(basketId, ConstantsBG.SigningBasketsStatus.ACTC.toString) - unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") - } else { - val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") - unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") - } - } - case Some(status) if status.toString == StrongCustomerAuthenticationStatus.failed.toString => - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - basket.map { i => - i.payments.map(_.map { i => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), REJECTED.toString, updatedCC) - }) - } - unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") - } - case _ => - Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) + challenge <- Future { + boxedChallenge match { + case Full(answered) => answered + case failure => + val (message, code) = challengeFailure(failure match { + case f: Failure => f.msg + case _ => InvalidConnectorResponse + }) + unboxFullOrFail(Empty: Box[ChallengeTrait], callContext, message, code) + } } + claimed <- Future(provider.transitionSigningBasketStatus( + basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(claimed.openOr(false)) + _ = startPaymentExecution(paymentIds, callContext) + _ <- Future(provider.transitionSigningBasketStatus( + basketId, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.ACTC.toString)) } yield { JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge) } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index da55c706f9..8786c2d6c8 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -906,7 +906,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def createSigningBasketResponseJson(basket: SigningBasketTrait): SigningBasketResponseJson = { SigningBasketResponseJson( basketId = basket.basketId, - transactionStatus = basket.status, + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.status), _links = SigningBasketLinksV13( self = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}"), status = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}/status"), @@ -917,7 +917,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status, + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), payments = basket.payments, consents = basket.consents, ) @@ -925,7 +925,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketStatusResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status, + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), payments = None, consents = None, ) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index 3f921bb12a..76875926ba 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -120,6 +120,17 @@ object BerlinGroupError { // endpoint is not a way to learn which basket ids exist. case "403" if message.contains("OBP-35051") => "RESOURCE_UNKNOWN" case "404" if message.contains("OBP-35052") => "RESOURCE_UNKNOWN" + case "409" if message.contains("OBP-35053") => "STATUS_INVALID" + case "403" if message.contains("OBP-35054") => "SERVICE_BLOCKED" + case "400" if message.contains("OBP-35055") => "SERVICE_INVALID" + case "400" if message.contains("OBP-35056") => "RESOURCE_UNKNOWN" + case "409" if message.contains("OBP-35057") => "REFERENCE_STATUS_INVALID" + case "400" if message.contains("OBP-35058") => "REFERENCE_MIX_INVALID" + // A wrong, expired or used-up one-time password on a signing basket. The standard's code for "the + // password/OTP is incorrect" is a 401 one; the basket answers these at 401 so it can use it. + case "401" if message.contains("OBP-40016") => "PSU_CREDENTIALS_INVALID" + case "401" if message.contains("OBP-20211") => "PSU_CREDENTIALS_INVALID" + case "401" if message.contains("OBP-40014") => "PSU_CREDENTIALS_INVALID" case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN" case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN" 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 282927a7e6..9b5bc89d70 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -881,6 +881,12 @@ object ErrorMessages { "PSU authentication, authentication method selection and confirmation code requests are not available for signing baskets. " val SigningBasketNotFound = "OBP-35051: Signing basket not found by BASKET_ID. " val SigningBasketAuthorisationNotFound = "OBP-35052: Signing basket authorisation not found by AUTHORISATION_ID. " + val SigningBasketStatusInvalid = "OBP-35053: The signing basket's status does not allow this operation. " + val SigningBasketAuthorisationDisabled = "OBP-35054: Authorising signing baskets is not enabled at this instance. " + val SigningBasketConsentNotSupported = "OBP-35055: This signing basket contains a consent, and authorising a consent through a signing basket is not supported yet. " + val SigningBasketMemberNotFound = "OBP-35056: A payment or consent named for the signing basket was not found. " + val SigningBasketMemberStatusInvalid = "OBP-35057: A payment or consent named for the signing basket is not in a state that can be authorised, or is already in another signing basket. " + val SigningBasketMemberMixInvalid = "OBP-35058: The payments and consents named for the signing basket cannot be authorised together. " val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. " val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. " val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. " diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 849c0daaae..db797ceb6f 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -30,12 +30,12 @@ package code.api.util.newstyle import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} import code.api.util.CallContext import code.api.util.Consent -import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, InvalidConnectorResponse, RegulatedEntityNotDeleted, SigningBasketAuthorisationNotFound, SigningBasketNotFound} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketNotFound} import code.bankconnectors.Connector import code.signingbaskets.SigningBasketX import code.util.Helper.{MdcLoggable, booleanToFuture} import com.openbankproject.commons.model.enums.ChallengeType -import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent, TransactionRequestId} +import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent} import net.liftweb.common.{Box, Empty} import scala.concurrent.Future @@ -153,26 +153,4 @@ object SigningBasketNewStyle extends MdcLoggable { c.basketId.contains(basketId) && c.challengeType == ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE.toString) (unboxFullOrFail(Box(ofThisBasket), callContext, SigningBasketAuthorisationNotFound, 404), cc) } - - def checkSigningBasketPayments(basketId: String, - callContext: Option[CallContext] - ): OBPReturnType[Boolean] = { - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - val existAll: Box[Boolean] = - basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), callContext).isDefined))) - if (existAll.getOrElse(false)) { - Some(true) - } else { // Fail due to nonexistent payment - val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") - unboxFullOrFail(Empty, callContext, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") - } - } map { - (_, callContext) - } map { - x => (unboxFullOrFail(x._1, callContext, RegulatedEntityNotDeleted, 400), x._2) - } - } - - } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 3a459dff21..61beeddbba 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -759,12 +759,54 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } - scenario("S3: a wrong answer changes nothing", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + scenario("S3: a wrong answer is the standard's incorrect-OTP refusal and changes nothing (IG §14.11 PSU_CREDENTIALS_INVALID, L11597)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { val started = startedBasket() - val response = answerAuthorisation(started.basketId, started.authorisationId, body = """{"scaAuthenticationData":"wrong"}""") - response.code should be >= 400 + expectRefusal( + answerAuthorisation(started.basketId, started.authorisationId, body = """{"scaAuthenticationData":"wrong"}"""), + 401, "PSU_CREDENTIALS_INVALID", "a wrong one-time password") storedBasketStatus(started.basketId) should equal(Some("RCVD")) started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + withClue("the authorisation can still be answered correctly: ") { + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + } + } + + scenario("S3: a client-credentials TPP relays the PSU's answer, and it is checked as the PSU the challenge names", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + val started = makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser1.name))) + started.code should equal(201) + val authorisationId = (started.body \ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId, as = clientCredentialsSession).code should equal(200) + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + + scenario("S2: the same correct answer sent twice at once is accepted once and refused once (contract 3.7)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 5).foreach { round => + val started = startedBasket() + val answers = (1 to 2).map(_ => Future(answerAuthorisation(started.basketId, started.authorisationId))) + val codes = Await.result(Future.sequence(answers), 60.seconds).map(_.code).sorted + withClue(s"round $round: ") { + codes should equal(List(200, 409)) + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + } + } + } + + scenario("D9: a basket cannot be authorised unless the instance enables it, and nothing changes while it is not", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") // signing_basket_authorisation_enabled is left at its default + val payment = lodgePayment() + val basketId = createBasket(List(payment)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + expectRefusal(answerAuthorisation(basketId, authorisationId), 403, "SERVICE_BLOCKED", "an instance that has not enabled it") + storedBasketStatus(basketId) should equal(Some("RCVD")) + storedPaymentStatus(payment) should equal(awaitingSca) + withClue("the answer was not consumed: ") { + Challenges.ChallengeProvider.vend.getChallenge(authorisationId).map(_.successful) should equal(net.liftweb.common.Full(false)) + } + setPropsValues("signing_basket_authorisation_enabled" -> "true") + answerAuthorisation(basketId, authorisationId).code should equal(200) } } From 65d6ce479565e63a95873d75e403eaa4c23b8be3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:48:34 +0200 Subject: [PATCH 13/40] fix: only delete or authorise a signing basket in a status that allows it DELETE set the basket to CANC whatever its status, so an authorised basket (ACTC) became cancelled, and POST authorisations minted a challenge for a cancelled basket. Both now require the status the standard's rule implies. DELETE: allowed while the basket is RCVD and no authorisation of it is finalised (L3399); the change is one conditional update from RCVD, so a final answer racing it has exactly one winner. Anything else is 409 STATUS_INVALID. Deleting an already cancelled basket answers 204 and changes nothing. POST authorisations: 409 STATUS_INVALID unless the basket is RCVD. The unconditional status writers are removed from the provider; every status change now goes through transitionSigningBasketStatus. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 27 ++++++++++++++++--- .../MappedSigningBasketProvider.scala | 12 --------- .../code/signingbaskets/SigningBasket.scala | 3 --- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index a376f9c255..172eb475d5 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -183,10 +183,25 @@ The resource identifications of these transactions are contained in the payload val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - _ <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) - _ <- Future { - SigningBasketX.signingBasketProvider.vend.deleteSigningBasket(basketid) - }.map(connectorEmptyResponse(_, callContext)) + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) + // Deleting a basket that is already cancelled changes nothing and is not an error. + alreadyCancelled = basket.basket.status == ConstantsBG.SigningBasketsStatus.CANC.toString + _ <- if (alreadyCancelled) Future.successful(true) else for { + // "As long as no (partial) authorisation has yet been applied" (L3399): the basket must + // still be RCVD and none of its authorisations may be finalised. + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + basket.basket.status == ConstantsBG.SigningBasketsStatus.RCVD.toString + } + (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketid, callContext) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + !challenges.exists(_.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised)) + } + // One conditional update. A final answer racing this delete claims the basket first or + // loses to it, and the loser is told so; never both. + cancelled <- Future(SigningBasketX.signingBasketProvider.vend.transitionSigningBasketStatus( + basketid, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.CANC.toString)) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(cancelled.openOr(false)) + } yield true } yield () } } @@ -356,6 +371,10 @@ Returns the status of a signing basket object. _ <- requireSupportedAuthorisationBody( cc.httpBody.getOrElse(""), answering = false, s"$InvalidJsonFormat The Json body should be empty, or the transactionAuthorisation body. ", callContext) + // An authorisation can only be started on a basket still waiting for one. + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + basket.basket.status == ConstantsBG.SigningBasketsStatus.RCVD.toString + } // Whose challenge this is, which is also where the OTP goes: the PSU, not the calling TPP. psuUserId <- SigningBasketNewStyle.bindAuthorisingPsu(basket, cc, callContext) (challenges, _) <- NewStyle.function.createChallengesC3( diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index 9642939e95..8b919ff4d7 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -59,18 +59,6 @@ object MappedSigningBasketProvider extends SigningBasketProvider { basket.map(i => SigningBasketContent(basket = i, payments = payments, consents = consents)) } - override def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] = { - val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)).map(_.Status(status).saveMe) - val (payments, consents) = membersOf(entityId) - basket.map(i => SigningBasketContent(basket = i, payments = payments, consents = consents)) - } - - override def deleteSigningBasket(id: String): Box[Boolean] = { - MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map { - _.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save - } - } - override def createSigningBasket(paymentIds: Option[List[String]], consentIds: Option[List[String]], consumerId: String, diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index 0f5e947906..41c5bd706e 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -43,9 +43,6 @@ trait SigningBasketProvider extends MdcLoggable { def getSigningBaskets(): List[SigningBasketTrait] def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] - // Unconditional writers, being replaced by transitionSigningBasketStatus. - def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] - def deleteSigningBasket(id: String): Box[Boolean] /** * Creates the basket and its members together, owned by the consumer that creates it. A failure From 9227051febe13ea57d41b7739173520033e69ffb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:50:55 +0200 Subject: [PATCH 14/40] refactor: share the payment ownership rule between initiation and signing baskets Http4sBGv13PIS.getOwnPaymentImpl decides whether a caller may address a payment: the TPP that lodged it, acting as a principal that is party to it. A signing basket has to ask the same question about every payment it is asked to hold, so move the rule, unchanged, into BerlinGroupPaymentAccess. The eleven payment routes keep calling their private helper, which now delegates. No behaviour change. --- .../group/v1_3/BerlinGroupPaymentAccess.scala | 85 +++++++++++++++++++ .../berlin/group/v1_3/Http4sBGv13PIS.scala | 39 +-------- 2 files changed, 88 insertions(+), 36 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupPaymentAccess.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupPaymentAccess.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupPaymentAccess.scala new file mode 100644 index 0000000000..968e679616 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupPaymentAccess.scala @@ -0,0 +1,85 @@ +/** +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.berlin.group.v1_3 + +import code.api.util.APIUtil.OBPReturnType +import code.api.util.ErrorMessages.PaymentNotInitiatedByCaller +import code.api.util.{CallContext, Consent, NewStyle} +import code.transactionrequests.TransactionRequests +import code.util.Helper +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{TransactionRequest, TransactionRequestId} + +/** + * Who may address a Berlin Group payment. Shared by the payment initiation routes and by the + * signing basket, which has to decide the same question about every payment it is asked to hold. + */ +object BerlinGroupPaymentAccess { + + /** + * Fetch a payment the caller is entitled to address. + * + * Berlin Group names a payment by its id alone — there is no account in the path — so nothing in + * the route ties the payment to whoever is calling. Fetching one must therefore also establish + * that the caller is the party that lodged it; otherwise any authenticated TPP holding a paymentId + * could read another TPP's payment, list or start authorisations on it, or cancel it. Under + * NextGenPSD2 a payment initiation resource belongs to the TPP that created it, and only that TPP + * addresses it afterwards. + * + * Two things have to line up, because Berlin Group binds a payment to the TPP and the ASPSP + * separately knows which PSU it is for. + * + * - The TPP. The consumer that lodged the payment is recorded on it, and a caller presenting a + * different one is refused even when it is acting for the same PSU: one TPP's mandate over a + * payment is not another's. Payments lodged before the consumer was recorded carry none, and + * fall back to the person check alone rather than becoming unaddressable. + * - The person. A payment records the principal that lodged it and, when it was lodged under a + * consent, the PSU it was lodged for; a caller presents the same two. Any overlap is enough, so + * a payment lodged on a client-credentials token can still be authorised under the PSU's token + * and the other way round. A payment carrying neither identity belongs to nobody. + */ + def getOwnPayment(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] = + for { + (transactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + initiators = Set(transactionRequest.user_id, transactionRequest.on_behalf_of_user_id).flatten.filter(_.nonEmpty) + callers = callContext.toSet[CallContext].flatMap(cc => cc.user.toOption.map(_.userId) ++ Consent.actingPsu(cc).map(_.userId)) + callingConsumer = callContext.flatMap(_.consumer.map(_.consumerId.get)) + // Read straight off the stored row rather than through the TransactionRequest model: which + // TPP lodged a payment is this guard's business, not something every REST connector needs on + // the wire, and that model's shape is a frozen contract. + lodgedByConsumer = TransactionRequests.transactionRequestProvider.vend + .getMappedTransactionRequest(TransactionRequestId(paymentId)) + .toOption.flatMap(tr => Consent.present(tr.mConsumerId.get)) + sameTpp = lodgedByConsumer.forall(lodgedBy => callingConsumer.contains(lodgedBy)) + _ <- Helper.booleanToFuture(s"$PaymentNotInitiatedByCaller Payment id: $paymentId.", 403, callContext) { + sameTpp && initiators.exists(callers) + } + } yield (transactionRequest, callContext) + +} 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 0f9e0e5a83..938ca95127 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 @@ -101,44 +101,11 @@ object Http4sBGv13PIS extends MdcLoggable { }.isDefined /** - * Fetch a payment the caller is entitled to address. - * - * Berlin Group names a payment by its id alone — there is no account in the path — so nothing in - * the route ties the payment to whoever is calling. Fetching one must therefore also establish - * that the caller is the party that lodged it; otherwise any authenticated TPP holding a paymentId - * could read another TPP's payment, list or start authorisations on it, or cancel it. Under - * NextGenPSD2 a payment initiation resource belongs to the TPP that created it, and only that TPP - * addresses it afterwards. - * - * Two things have to line up, because Berlin Group binds a payment to the TPP and the ASPSP - * separately knows which PSU it is for. - * - * - The TPP. The consumer that lodged the payment is recorded on it, and a caller presenting a - * different one is refused even when it is acting for the same PSU: one TPP's mandate over a - * payment is not another's. Payments lodged before the consumer was recorded carry none, and - * fall back to the person check alone rather than becoming unaddressable. - * - The person. A payment records the principal that lodged it and, when it was lodged under a - * consent, the PSU it was lodged for; a caller presents the same two. Any overlap is enough, so - * a payment lodged on a client-credentials token can still be authorised under the PSU's token - * and the other way round. A payment carrying neither identity belongs to nobody. + * Fetch a payment the caller is entitled to address: the TPP that lodged it, acting as a principal + * that is party to it. The rule lives in BerlinGroupPaymentAccess, which the signing basket shares. */ private def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] = - for { - (transactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) - initiators = Set(transactionRequest.user_id, transactionRequest.on_behalf_of_user_id).flatten.filter(_.nonEmpty) - callers = callContext.toSet[CallContext].flatMap(cc => cc.user.toOption.map(_.userId) ++ Consent.actingPsu(cc).map(_.userId)) - callingConsumer = callContext.flatMap(_.consumer.map(_.consumerId.get)) - // Read straight off the stored row rather than through the TransactionRequest model: which - // TPP lodged a payment is this guard's business, not something every REST connector needs on - // the wire, and that model's shape is a frozen contract. - lodgedByConsumer = TransactionRequests.transactionRequestProvider.vend - .getMappedTransactionRequest(TransactionRequestId(paymentId)) - .toOption.flatMap(tr => Consent.present(tr.mConsumerId.get)) - sameTpp = lodgedByConsumer.forall(lodgedBy => callingConsumer.contains(lodgedBy)) - _ <- Helper.booleanToFuture(s"$PaymentNotInitiatedByCaller Payment id: $paymentId.", 403, callContext) { - sameTpp && initiators.exists(callers) - } - } yield (transactionRequest, callContext) + BerlinGroupPaymentAccess.getOwnPayment(paymentId, callContext) /** * Shared business logic for all three initiate-payment variants (payments / periodic-payments / From a9f3155a903ec0304112b24ca5368b44e0ad4864 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 22:55:25 +0200 Subject: [PATCH 15/40] feat: hold each payment and consent in one active signing basket at a time Nothing stopped the same payment being put in two baskets, so two authorisations could each try to book it. Record which basket holds a member in a claim row, unique on the member, written together with the basket. A basket naming a member another active basket holds is refused with 409 REFERENCE_STATUS_INVALID and leaves nothing behind. The claim is released when the basket is cancelled or authorised, so the member can join another basket. There is no permanent unique constraint on the member itself. A payment and a consent that happen to share an id do not collide: the claim key carries the member type. The check in front of the insert gives the usual answer, and the unique index decides when two requests get past it together. Members are now read back in the order they were submitted. --- .../main/scala/bootstrap/liftweb/Boot.scala | 3 +- .../v1_3/Http4sBGv13SigningBaskets.scala | 12 +++- .../MappedSigningBasketProvider.scala | 42 +++++++++++-- .../code/signingbaskets/SigningBasket.scala | 9 +++ .../MappedSigningBasketProviderTest.scala | 61 +++++++++++++++++-- .../scala/code/util/MappedClassNameTest.scala | 1 + 6 files changed, 116 insertions(+), 12 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index a68570e7c7..41b8a29838 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -125,7 +125,7 @@ import code.regulatedentities.attribute.RegulatedEntityAttribute import code.counterpartyattribute.{CounterpartyAttribute => CounterpartyAttributeMapper} import code.scheduler._ import code.scope.{MappedScope, MappedUserScope, Scope} -import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketPayment} +import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketMemberClaim, MappedSigningBasketPayment} import code.socialmedia.MappedSocialMedia import code.standingorders.StandingOrder import code.taxresidence.MappedTaxResidence @@ -1004,6 +1004,7 @@ object ToSchemify extends MdcLoggable { MappedSigningBasket, MappedSigningBasketPayment, MappedSigningBasketConsent, + MappedSigningBasketMemberClaim, MappedRegulatedEntity, AtmAttribute, AbacRule, diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 172eb475d5..b901da5183 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -47,7 +47,7 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.enums.TransactionRequestStatus.{COMPLETED, REJECTED} import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} -import com.openbankproject.commons.model.{ChallengeTrait, TransactionRequestId} +import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketTrait, TransactionRequestId} import net.liftweb.common.{Box, Empty, Failure, Full} import com.openbankproject.commons.util.json import org.json4s.Formats @@ -124,7 +124,12 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { consumerId, None ) - }.map(connectorEmptyResponse(_, callContext)) + }.map { + // A member that another active basket already holds: the standard's REFERENCE_STATUS_INVALID. + case Failure(SigningBasketMemberStatusInvalid, _, _) => + unboxFullOrFail(Empty: Box[SigningBasketTrait], callContext, SigningBasketMemberStatusInvalid, 409) + case created => connectorEmptyResponse(created, callContext) + } } yield { createSigningBasketResponseJson(signingBasket) } @@ -201,6 +206,8 @@ The resource identifications of these transactions are contained in the payload cancelled <- Future(SigningBasketX.signingBasketProvider.vend.transitionSigningBasketStatus( basketid, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.CANC.toString)) _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(cancelled.openOr(false)) + // The members are free to join another basket. + _ <- Future(SigningBasketX.signingBasketProvider.vend.releaseSigningBasketMembers(basketid)) } yield true } yield () } @@ -550,6 +557,7 @@ This applies in the following scenarios: _ = startPaymentExecution(paymentIds, callContext) _ <- Future(provider.transitionSigningBasketStatus( basketId, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.ACTC.toString)) + _ <- Future(provider.releaseSigningBasketMembers(basketId)) } yield { JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge) } diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index 8b919ff4d7..01bbdf5b02 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -30,23 +30,26 @@ package code.signingbaskets import code.api.berlin.group.ConstantsBG import code.util.MappedUUID import com.openbankproject.commons.model.{SigningBasketConsentTrait, SigningBasketContent, SigningBasketPaymentTrait, SigningBasketTrait} -import net.liftweb.common.Box +import code.api.util.ErrorMessages.SigningBasketMemberStatusInvalid +import net.liftweb.common.{Box, Failure, Full} import net.liftweb.common.Box.tryo import net.liftweb.db.DB import net.liftweb.mapper._ import net.liftweb.util.DefaultConnectionIdentifier object MappedSigningBasketProvider extends SigningBasketProvider { + private class MemberAlreadyHeld extends RuntimeException("A member of the basket is already held by another basket") + def getSigningBaskets(): List[SigningBasketTrait] = { MappedSigningBasket.findAll() } private def membersOf(basketId: String): (Option[List[String]], Option[List[String]]) = { - val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, basketId)).map(_.paymentId) match { + val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, basketId), OrderBy(MappedSigningBasketPayment.id, Ascending)).map(_.paymentId) match { case Nil => None case members => Some(members) } - val consents = MappedSigningBasketConsent.findAll(By(MappedSigningBasketConsent.BasketId, basketId)).map(_.consentId) match { + val consents = MappedSigningBasketConsent.findAll(By(MappedSigningBasketConsent.BasketId, basketId), OrderBy(MappedSigningBasketConsent.id, Ascending)).map(_.consentId) match { case Nil => None case members => Some(members) } @@ -68,6 +71,8 @@ object MappedSigningBasketProvider extends SigningBasketProvider { // connection is the request's own, whose rollback is not ours to call, so a failure part way is // also undone by hand: nothing of a basket that was not fully created is left behind. var created: Option[MappedSigningBasket] = None + val memberKeys = + paymentIds.getOrElse(Nil).map(id => s"payment:$id") ::: consentIds.getOrElse(Nil).map(id => s"consent:$id") val result = tryo { DB.use(DefaultConnectionIdentifier) { _ => val entity = MappedSigningBasket.create @@ -80,6 +85,13 @@ object MappedSigningBasketProvider extends SigningBasketProvider { throw new Error(entity.validate.map(_.msg.toString()).mkString(";")) } created = Some(entity) + // Held by one active basket at a time. The check is the usual answer; the unique index on the + // claim is what holds if two requests get past it together. + memberKeys.foreach { key => + if (MappedSigningBasketMemberClaim.find(By(MappedSigningBasketMemberClaim.MemberKey, key)).isDefined) + throw new MemberAlreadyHeld + MappedSigningBasketMemberClaim.create.MemberKey(key).BasketId(entity.basketId).saveMe() + } paymentIds.getOrElse(Nil).foreach { paymentId => MappedSigningBasketPayment.create.BasketId(entity.basketId).PaymentId(paymentId).saveMe() } @@ -91,14 +103,21 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } if (result.isEmpty) created.foreach { basket => tryo { + MappedSigningBasketMemberClaim.bulkDelete_!!(By(MappedSigningBasketMemberClaim.BasketId, basket.basketId)) MappedSigningBasketPayment.bulkDelete_!!(By(MappedSigningBasketPayment.BasketId, basket.basketId)) MappedSigningBasketConsent.bulkDelete_!!(By(MappedSigningBasketConsent.BasketId, basket.basketId)) basket.delete_! } } - result + result match { + case Failure(_, Full(_: MemberAlreadyHeld), _) => Failure(SigningBasketMemberStatusInvalid) + case other => other + } } + override def releaseSigningBasketMembers(basketId: String): Box[Boolean] = + tryo { MappedSigningBasketMemberClaim.bulkDelete_!!(By(MappedSigningBasketMemberClaim.BasketId, basketId)) } + override def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] = tryo { DB.runUpdate( @@ -174,3 +193,18 @@ object MappedSigningBasketConsent extends MappedSigningBasketConsent with LongKe override def dbIndexes = Index(BasketId, ConsentId) :: super.dbIndexes } +/** + * Which basket is holding a payment or consent. A row exists while the basket is active and is deleted + * when it reaches a final status, so a member can be in one active basket at a time without a permanent + * unique constraint on the member itself. + */ +class MappedSigningBasketMemberClaim extends LongKeyedMapper[MappedSigningBasketMemberClaim] with IdPK with CreatedUpdated { + override def getSingleton = MappedSigningBasketMemberClaim + // "payment:" or "consent:" + object MemberKey extends MappedString(this, 255) + object BasketId extends MappedUUID(this) +} +object MappedSigningBasketMemberClaim extends MappedSigningBasketMemberClaim with LongKeyedMetaMapper[MappedSigningBasketMemberClaim] { + override def dbTableName = "SigningBasketMemberClaim" + override def dbIndexes = UniqueIndex(MemberKey) :: Index(BasketId) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index 41c5bd706e..7154f7b72c 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -47,6 +47,9 @@ trait SigningBasketProvider extends MdcLoggable { /** * Creates the basket and its members together, owned by the consumer that creates it. A failure * part way leaves nothing behind. `psuUserId` is the PSU the request already names, if any. + * + * A payment or consent may be held by one active basket at a time. Creating a basket that names one + * already held fails with SigningBasketMemberStatusInvalid and leaves nothing behind. */ def createSigningBasket(paymentIds: Option[List[String]], consentIds: Option[List[String]], @@ -54,6 +57,12 @@ trait SigningBasketProvider extends MdcLoggable { psuUserId: Option[String] ): Box[SigningBasketTrait] + /** + * Frees the payments and consents a basket was holding, so they can join another basket. Called when + * a basket reaches a final status. + */ + def releaseSigningBasketMembers(basketId: String): Box[Boolean] + /** * Moves a basket from one status to another only if it still has the status the caller read. * One conditional update, so two callers racing for the same transition have exactly one winner. diff --git a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala index 276b6ee537..bb2444f1e1 100644 --- a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -37,19 +37,25 @@ class MappedSigningBasketProviderTest extends ServerSetup { private val provider = MappedSigningBasketProvider - private def newBasket(consumerId: String = "consumer-1", psuUserId: Option[String] = None) = - provider.createSigningBasket(Some(List("payment-1", "payment-2")), Some(List("consent-1")), consumerId, psuUserId) + // Members are unique per basket: a payment or consent can be held by one active basket at a time. + private def uuid() = java.util.UUID.randomUUID().toString + + private def newBasket(consumerId: String = "consumer-1", psuUserId: Option[String] = None, + payments: List[String] = List(uuid(), uuid()), consents: List[String] = List(uuid())) = + provider.createSigningBasket(Some(payments), Some(consents), consumerId, psuUserId) .openOrThrowException("the basket must be created") feature("a signing basket records who created it") { scenario("the creating consumer, the named PSU, the creation time and the members are stored") { - val basket = newBasket("consumer-a", Some("psu-a")) + val payments = List(uuid(), uuid()) + val consents = List(uuid()) + val basket = newBasket("consumer-a", Some("psu-a"), payments, consents) val stored = provider.getSigningBasketByBasketId(basket.basketId).openOrThrowException("stored") stored.basket.status should equal("RCVD") stored.basket.consumerId should equal(Some("consumer-a")) stored.basket.psuUserId should equal(Some("psu-a")) - stored.payments should equal(Some(List("payment-1", "payment-2"))) - stored.consents should equal(Some(List("consent-1"))) + stored.payments should equal(Some(payments)) + stored.consents should equal(Some(consents)) MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basket.basketId)).map(_.createdAt.get.getTime > 0) should equal(net.liftweb.common.Full(true)) } @@ -107,4 +113,49 @@ class MappedSigningBasketProviderTest extends ServerSetup { provider.getSigningBasketByBasketId(basket.basketId).map(_.basket.psuUserId) should equal(net.liftweb.common.Full(Some("psu-named"))) } } + + feature("a payment or consent is held by one active basket at a time") { + scenario("a second basket naming a held member is refused and leaves nothing behind") { + val (heldPayment, heldConsent, freePayment) = (uuid(), uuid(), uuid()) + val first = provider.createSigningBasket(Some(List(heldPayment)), Some(List(heldConsent)), "consumer-1", None) + .openOrThrowException("the first basket must be created") + val basketsBefore = MappedSigningBasket.count() + val claimsBefore = MappedSigningBasketMemberClaim.count() + + val refused = provider.createSigningBasket(Some(List(freePayment, heldPayment)), None, "consumer-1", None) + refused should equal(net.liftweb.common.Failure(code.api.util.ErrorMessages.SigningBasketMemberStatusInvalid)) + + MappedSigningBasket.count() should equal(basketsBefore) + MappedSigningBasketMemberClaim.count() should equal(claimsBefore) + MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.PaymentId, freePayment)) shouldBe empty + provider.getSigningBasketByBasketId(first.basketId).map(_.payments) should equal(net.liftweb.common.Full(Some(List(heldPayment)))) + } + + scenario("releasing a basket's members lets another basket take them") { + val released = uuid() + val first = provider.createSigningBasket(Some(List(released)), None, "consumer-1", None).openOrThrowException("x") + provider.createSigningBasket(Some(List(released)), None, "consumer-1", None).isEmpty should be(true) + provider.releaseSigningBasketMembers(first.basketId).openOrThrowException("x") + provider.createSigningBasket(Some(List(released)), None, "consumer-1", None).isDefined should be(true) + } + + scenario("a payment and a consent that share an id do not collide") { + val sameId = uuid() + provider.createSigningBasket(Some(List(sameId)), None, "consumer-1", None).isDefined should be(true) + provider.createSigningBasket(None, Some(List(sameId)), "consumer-1", None).isDefined should be(true) + } + + scenario("two requests racing for the same member: exactly one basket is created") { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 10).foreach { round => + val member = uuid() + val callers = (1 to 6).map(_ => Future(provider.createSigningBasket(Some(List(member)), None, "consumer-1", None).isDefined)) + val created = Await.result(Future.sequence(callers), 60.seconds) + withClue(s"round $round: ") { + created.count(identity) should equal(1) + MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.PaymentId, member)).size should equal(1) + } + } + } + } } diff --git a/obp-api/src/test/scala/code/util/MappedClassNameTest.scala b/obp-api/src/test/scala/code/util/MappedClassNameTest.scala index 858f87c003..3fb9280aa8 100644 --- a/obp-api/src/test/scala/code/util/MappedClassNameTest.scala +++ b/obp-api/src/test/scala/code/util/MappedClassNameTest.scala @@ -144,6 +144,7 @@ class MappedClassNameTest extends FeatureSpec { "code.signingbaskets.MappedSigningBasketConsent", "code.signingbaskets.MappedSigningBasket", "code.signingbaskets.MappedSigningBasketPayment", + "code.signingbaskets.MappedSigningBasketMemberClaim", "code.CustomerDependants.MappedCustomerDependant", ) From 2f14b54c05845ecf79993c22eeb4402f69d69504 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 23:07:22 +0200 Subject: [PATCH 16/40] fix: admit only members a TPP may authorise when creating a signing basket A basket was created for any ids at all: invented ones, payments another TPP lodged, payments already booked. Admit each member against what it is. A payment must be one the caller may address, under the rule the payment routes use, and still awaiting SCA, and must be a SEPA credit transfer. A consent must be one the caller may address under the consent rule, created through the Berlin Group API, and not yet authorised or ended. A member that does not exist and one that is not the caller's are answered alike (400 RESOURCE_UNKNOWN), so the endpoint does not reveal which ids exist; one in the wrong state is 409 REFERENCE_STATUS_INVALID; one the ASPSP does not accept is 400 REFERENCE_MIX_INVALID. Where members name a PSU they must all name the same one, and it must be the PSU the request names (a genuine PSU in the session, or PSU-ID). A client-credentials TPP's own pseudo-user is not a PSU and is ignored. The basket records that PSU; otherwise it is bound when an authorisation is started. The PSP role needed follows the members: PISP for payments, AISP for consents, both for a mix. Periodic payments cannot be excluded: the recurrence of a periodic payment is never stored, so one cannot be told from a single payment. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 13 ++- .../util/newstyle/SigningBasketNewStyle.scala | 97 ++++++++++++++++++- .../v1_3/SigningBasketServiceSBSApiTest.scala | 36 +++++-- 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index b901da5183..a642e861b7 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -32,7 +32,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect._ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getPropsAsBoolValue, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getPropsAsBoolValue, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, passesPsd2Pisp, unboxFullOrFail} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats @@ -101,9 +101,8 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { EndpointHelpers.executeFutureCreatedWithHeaders(req) { val cc = req.callContext val callContext = Some(cc) + val failMsg = s"$InvalidJsonFormat The Json body should be the $PostSigningBasketJsonV13 " for { - _ <- passesPsd2Pisp(callContext) - failMsg = s"$InvalidJsonFormat The Json body should be the $PostSigningBasketJsonV13 " postJson <- NewStyle.function.tryons(failMsg, 400, callContext) { json.parse(cc.httpBody.getOrElse("")).extract[PostSigningBasketJsonV13] } @@ -114,15 +113,21 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { _ <- booleanToFuture(failMsg, cc = callContext) { idLists.nonEmpty && idLists.forall(ids => ids.nonEmpty && ids.distinct.size == ids.size) } + // Which role the TPP needs follows from what it names: payments need PISP, consents AISP, both for a mix. + _ <- if (postJson.paymentIds.exists(_.nonEmpty)) passesPsd2Pisp(callContext) else Future.successful(()) + _ <- if (postJson.consentIds.exists(_.nonEmpty)) passesPsd2Aisp(callContext) else Future.successful(()) // The basket belongs to the TPP that creates it; nothing else identifies who may address it later. consumerId <- Future.successful(cc.consumer.map(_.consumerId.get)) .map(unboxFullOrFail(_, callContext, AuthenticatedUserIsRequired, 401)) + // Every member must be one this TPP may address and SCA can still authorise. + psuUserId <- SigningBasketNewStyle.admitMembers( + postJson.paymentIds.getOrElse(Nil), postJson.consentIds.getOrElse(Nil), cc, callContext) signingBasket <- Future { SigningBasketX.signingBasketProvider.vend.createSigningBasket( postJson.paymentIds, postJson.consentIds, consumerId, - None + psuUserId ) }.map { // A member that another active basket already holds: the standard's REFERENCE_STATUS_INVALID. diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index db797ceb6f..531049135b 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -29,14 +29,18 @@ package code.api.util.newstyle import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} import code.api.util.CallContext +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.BerlinGroupPaymentAccess import code.api.util.Consent -import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketNotFound} +import code.consent.{ConsentStatus, Consents} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketMemberMixInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} import code.bankconnectors.Connector import code.signingbaskets.SigningBasketX +import code.users.Users import code.util.Helper.{MdcLoggable, booleanToFuture} -import com.openbankproject.commons.model.enums.ChallengeType +import com.openbankproject.commons.model.enums.{ChallengeType, TransactionRequestTypes} import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent} -import net.liftweb.common.{Box, Empty} +import net.liftweb.common.{Box, Empty, Full} import scala.concurrent.Future @@ -113,6 +117,93 @@ object SigningBasketNewStyle extends MdcLoggable { case Left(_) => unboxFullOrFail(Empty: Box[(SigningBasketContent, Option[CallContext])], callContext, SigningBasketNotFound, 403) } + private def refuseMember(message: String, code: Int, callContext: Option[CallContext]): Future[Nothing] = + booleanToFuture(message, failCode = code, cc = callContext)(false).map(_ => throw new IllegalStateException(message)) + + /** + * A payment may join a basket if the caller may address it, it is a single SEPA payment still + * waiting for SCA. Whether the caller may is BerlinGroupPaymentAccess's rule, the same one the + * payment routes apply; a payment that does not exist and one that is not the caller's are answered + * alike, so the endpoint does not reveal which payment ids exist. Returns the PSU the payment names, + * if it names one. + */ + private def admitPayment(paymentId: String, cc: CallContext, callContext: Option[CallContext]): Future[Option[String]] = + for { + (payment, _) <- BerlinGroupPaymentAccess.getOwnPayment(paymentId, callContext) + .recoverWith { case _ => refuseMember(SigningBasketMemberNotFound, 400, callContext) } + // Only single SEPA credit transfers. A periodic payment cannot be told apart once stored: the + // routes pass the service as periodic_payments and the provider compares it to periodic-payments, + // so the recurrence of a periodic payment is never recorded (and bulk payments are not offered). + _ <- booleanToFuture(SigningBasketMemberMixInvalid, failCode = 400, cc = callContext) { + payment.`type` == TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString + } + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + awaitingScaPaymentStatuses.contains(payment.status) + } + } yield payment.on_behalf_of_user_id.flatMap(Consent.present).filter(isPerson(_, cc)) + + /** + * Whether this user is a person rather than the calling TPP's own pseudo-user. A client-credentials + * token resolves to an auto-created user keyed on the consumer's own key, and a payment lodged on one + * records it as the user it was made for; it names nobody a basket could be bound to. + */ + private def isPerson(userId: String, cc: CallContext): Boolean = + Users.users.vend.getUserByUserId(userId).toOption + .forall(user => !cc.consumer.map(_.key.get).contains(user.idGivenByProvider)) + + // A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked, + // rejected or cancelled, or is being authorised some other way. + private val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED") + + /** + * A consent may join a basket if the caller may address it under the rule consents use, it was + * created through the Berlin Group API, and it has not been authorised or ended. Returns the PSU the + * consent is bound to, if it is. + */ + private def admitConsent(consentId: String, cc: CallContext, callContext: Option[CallContext]): Future[Option[String]] = + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)).flatMap { + case Full(found) => Future.successful(found) + case _ => refuseMember(SigningBasketMemberNotFound, 400, callContext) + } + refusal = Consent.checkBerlinGroupConsentAccess( + consent.userId, consent.consumerId, + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get), + callerIsScaFrontEnd = false) + _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext) { + refusal.isEmpty && consent.apiStandard == ConstantsBG.berlinGroupVersion1.apiStandard + } + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + consent.status == ConsentStatus.received.toString + } + } yield Consent.present(consent.userId) + + /** + * Admit the members of a new basket, and say whom the basket is for. + * + * Every member must be one the caller may address, in a state SCA can still authorise. All members + * must be for the same PSU where they name one, and that must be the PSU the request names (a genuine + * PSU in the session, or PSU-ID) where it names one. Members that name nobody leave the PSU to be bound + * when an authorisation is started. + */ + def admitMembers(paymentIds: List[String], + consentIds: List[String], + cc: CallContext, + callContext: Option[CallContext]): Future[Option[String]] = + for { + paymentPsus <- paymentIds.foldLeft(Future.successful(List.empty[Option[String]])) { (acc, id) => + acc.flatMap(done => admitPayment(id, cc, callContext).map(done :+ _)) + } + consentPsus <- consentIds.foldLeft(Future.successful(List.empty[Option[String]])) { (acc, id) => + acc.flatMap(done => admitConsent(id, cc, callContext).map(done :+ _)) + } + namedPsu <- Consent.resolvePsuIdHeader(cc, callContext).map(_.orElse(Consent.genuinePsu(cc).map(_.userId))) + memberPsus = (paymentPsus ++ consentPsus).flatten.toSet + _ <- booleanToFuture(SigningBasketMemberMixInvalid, failCode = 400, cc = callContext) { + memberPsus.size <= 1 && namedPsu.forall(named => memberPsus.forall(_ == named)) + } + } yield namedPsu.orElse(memberPsus.headOption) + /** * The PSU an authorisation on this basket is for, bound to the basket. * diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 61beeddbba..6cec52677b 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -38,6 +38,7 @@ import code.api.util.ErrorMessages._ import code.model.TokenType import code.model.dataAccess.{BankAccountRouting, MappedBankAccount} import code.setup.APIResponse +import com.openbankproject.commons.model.User import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketPayment, SigningBasketX} import code.token.Tokens import code.transactionChallenge.Challenges @@ -107,11 +108,11 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { * a payment in. A payment of 10 is booked on creation (ACCP) and can no longer be authorised by * anything. */ - private def lodgePayment(amount: String = "2001"): String = { + private def lodgePayment(amount: String = "2001", as: Option[(Consumer, Token)] = user1, initiator: User = resourceUser1): String = { val ibanFrom = ibanAccounts.head val ibanTo = ibanAccounts.last Views.views.vend.systemView(ViewId(SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)).foreach(view => - Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, resourceUser1) + Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, initiator) ) val initiatePaymentJson = s"""{ @@ -120,12 +121,16 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, | "creditorName": "TestCreditor" |}""".stripMargin - val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1) + val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (as) val response: APIResponse = makePostRequest(requestPost, initiatePaymentJson) - response.code should equal(201) + withClue(s"lodging a payment of $amount: ") { response.code should equal(201) } response.body.extract[InitiatePaymentResponseJson].paymentId } + /** A payment lodged the way a client-credentials TPP lodges one: on its own session, with no PSU in it. */ + private def lodgePaymentAsClientCredentialsTpp(): String = + lodgePayment(as = clientCredentialsSession, initiator = pseudoUserOfTestConsumer) + private def createRealPaymentId(): String = lodgePayment() /** The stored status of a payment that awaits SCA, and of one that was booked on creation. */ @@ -680,7 +685,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { feature("BG v1.3 signing baskets - an authorisation is minted for the PSU, which is where the one-time password goes") { scenario("S1: a client-credentials TPP naming the PSU in PSU-ID gets a challenge for that PSU, and the basket binds to them", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { setPropsValues("suggested_default_sca_method" -> "DUMMY") - val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + val basketId = createBasket(List(lodgePaymentAsClientCredentialsTpp()), as = clientCredentialsSession) val response = makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser1.name))) response.code should equal(201) val authorisationId = (response.body \ "authorisationId").extract[String] @@ -689,7 +694,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } scenario("S1: a client-credentials TPP that names nobody gets no challenge (L11597, IG §14.11 PSU_CREDENTIALS_INVALID)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { - val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + val basketId = createBasket(List(lodgePaymentAsClientCredentialsTpp()), as = clientCredentialsSession) expectRefusal(startAuthorisation(basketId, as = clientCredentialsSession), 401, "PSU_CREDENTIALS_INVALID", "no PSU anywhere") expectRefusal(makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", "nobody-by-this-name"))), 401, "PSU_CREDENTIALS_INVALID", "an unknown PSU-ID") storedChallengeCount(basketId) should equal(0) @@ -697,7 +702,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { scenario("S1: once a PSU is bound, a PSU-ID naming someone else is refused like any other refusal to address the basket", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { setPropsValues("suggested_default_sca_method" -> "DUMMY") - val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + val basketId = createBasket(List(lodgePaymentAsClientCredentialsTpp()), as = clientCredentialsSession) makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser1.name))).code should equal(201) val challengesBefore = storedChallengeCount(basketId) expectRefusal(makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser2.name))), 403, "RESOURCE_UNKNOWN", "another PSU") @@ -742,6 +747,21 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } + feature("BG v1.3 signing baskets - a consent joins a basket only if its TPP is the basket's and it is still to be authorised") { + scenario("D8: an unauthorised consent of the same TPP is admitted; an authorised one, or another TPP's, is not", BerlinGroupV1_3, SBS, createSigningBasket) { + val own = createUnclaimedBerlinGroupConsent().consentId + postBasket(s"""{"consentIds":${idList(List(own))}}""").code should equal(201) + + val authorised = createUnclaimedBerlinGroupConsent().consentId + code.consent.Consents.consentProvider.vend.updateConsentStatus(authorised, code.consent.ConsentStatus.valid) + expectRefusal(postBasket(s"""{"consentIds":${idList(List(authorised))}}"""), 409, "REFERENCE_STATUS_INVALID", "an authorised consent") + + val anotherTpp = createUnclaimedBerlinGroupConsent().consentId + expectRefusal(postBasket(s"""{"consentIds":${idList(List(anotherTpp))}}""", as = user2), 400, "RESOURCE_UNKNOWN", "another TPP's consent") + expectRefusal(postBasket(s"""{"consentIds":${idList(List(UUID.randomUUID().toString))}}"""), 400, "RESOURCE_UNKNOWN", "a consent nobody created") + } + } + // ───────────────────────── challenge binding and ordering: S3 ───────────────────────── feature("BG v1.3 signing baskets - an authorisation can only be answered through the basket it was issued for") { @@ -773,7 +793,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { scenario("S3: a client-credentials TPP relays the PSU's answer, and it is checked as the PSU the challenge names", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation, updateSigningBasketPsuData) { enableBasketAuthorisation() - val basketId = createBasket(List(lodgePayment()), as = clientCredentialsSession) + val basketId = createBasket(List(lodgePaymentAsClientCredentialsTpp()), as = clientCredentialsSession) val started = makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser1.name))) started.code should equal(201) val authorisationId = (started.body \ "authorisationId").extract[String] From 6ca632aef114dc654879dafe84cde904517227dc Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 23:14:51 +0200 Subject: [PATCH 17/40] fix: require the PSP role that a signing basket's members call for Every signing basket operation demanded the payment initiation role, which does not fit a basket of consents. The role now follows the members: PISP for payments, AISP for consents, both for a mix. On an existing basket it is checked after the caller is known to be entitled to it, so a role check cannot be used to tell which baskets exist, and it is not asked of the ASPSP's own SCA front end, which drives the authorisation under Redirect without a certificate of its own. Add SigningBasketSignedRequestTest. The shared test setup runs with no mandatory headers and no certificate check, so nothing exercised the request signature, the mandatory headers or the PSP role on these routes. It registers the TPP's certificate as a regulated entity and signs every call: unsigned and unregistered requests are refused, a TPP with the payment initiation role creates a basket and receives Location and ASPSP-SCA-Approach, and one with only the account information role is refused with ROLE_INVALID. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 7 - .../util/newstyle/SigningBasketNewStyle.scala | 23 ++- .../v1_3/SigningBasketSignedRequestTest.scala | 145 ++++++++++++++++++ 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index a642e861b7..f4a3a8ddbc 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -192,7 +192,6 @@ The resource identifications of these transactions are contained in the payload EndpointHelpers.executeDelete(req) { cc => val callContext = Some(cc) for { - _ <- passesPsd2Pisp(callContext) (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) // Deleting a basket that is already cancelled changes nothing and is not an error. alreadyCancelled = basket.basket.status == ConstantsBG.SigningBasketsStatus.CANC.toString @@ -244,7 +243,6 @@ Nevertheless, single transactions might be cancelled on an individual basis on t EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) for { - _ <- passesPsd2Pisp(callContext) (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) } yield { getSigningBasketResponseJson(basket) @@ -277,7 +275,6 @@ Returns the content of an signing basket object.""", EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) for { - _ <- passesPsd2Pisp(callContext) _ <- SigningBasketNewStyle.getOwnBasket(basketid, AuthorisationOperation, callContext) (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketid, callContext) } yield { @@ -312,7 +309,6 @@ This function returns an array of hyperlinks to all generated authorisation sub- EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) for { - _ <- passesPsd2Pisp(callContext) _ <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) (challenge, _) <- SigningBasketNewStyle.getBasketAuthorisation(basketId, authorisationId, callContext) } yield { @@ -345,7 +341,6 @@ This method returns the SCA status of a signing basket's authorisation sub-resou EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) for { - _ <- passesPsd2Pisp(callContext) (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) } yield { getSigningBasketStatusResponseJson(basket) @@ -378,7 +373,6 @@ Returns the status of a signing basket object. val cc = req.callContext val callContext = Some(cc) for { - _ <- passesPsd2Pisp(callContext) (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) _ <- requireSupportedAuthorisationBody( cc.httpBody.getOrElse(""), answering = false, @@ -505,7 +499,6 @@ This applies in the following scenarios: val callContext = Some(cc) val provider = SigningBasketX.signingBasketProvider.vend for { - _ <- passesPsd2Pisp(callContext) (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketId, AuthorisationOperation, callContext) (startedChallenge, _) <- SigningBasketNewStyle.getBasketAuthorisation(basketId, authorisationId, callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 531049135b..f1f94beab6 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -27,7 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.api.util.newstyle -import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} +import code.api.util.APIUtil.{OBPReturnType, passesPsd2Aisp, passesPsd2Pisp, unboxFullOrFail} import code.api.util.CallContext import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.BerlinGroupPaymentAccess @@ -115,6 +115,27 @@ object SigningBasketNewStyle extends MdcLoggable { } map { case Right(content) => (content, callContext) case Left(_) => unboxFullOrFail(Empty: Box[(SigningBasketContent, Option[CallContext])], callContext, SigningBasketNotFound, 403) + } flatMap { case (content, cc) => + // Only once the caller is known to be entitled to the basket, so a role check cannot be used to + // tell a basket that exists from one that does not. + passesRolesOfMembers(content, access, callContext).map(_ => (content, cc)) + } + + /** + * The PSP roles the members of a basket call for: PISP for payments, AISP for consents, both for a + * mix. The ASPSP's own SCA front end is not a payment or account information service provider, and + * acts on the authorisation under Redirect without a certificate of its own. + */ + private def passesRolesOfMembers(content: SigningBasketContent, + access: BasketAccess, + callContext: Option[CallContext]): Future[Unit] = { + val frontEnd = access == AuthorisationOperation && + Consent.isScaFrontEnd(callContext.flatMap(_.consumer.map(_.consumerId.get))) + if (frontEnd) Future.successful(()) + else for { + _ <- if (content.payments.exists(_.nonEmpty)) passesPsd2Pisp(callContext) else Future.successful(()) + _ <- if (content.consents.exists(_.nonEmpty)) passesPsd2Aisp(callContext) else Future.successful(()) + } yield () } private def refuseMember(message: String, code: Int, callContext: Option[CallContext]): Future[Nothing] = diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala new file mode 100644 index 0000000000..f8b7989f1d --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.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.api.berlin.group.v1_3 + +import code.api.Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID +import code.api.berlin.group.signing.{PSD2RequestSigner, PSD2SigningTestSupport} +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ErrorMessagesBG, InitiatePaymentResponseJson, SigningBasketResponseJson} +import code.api.util.APIUtil.OAuth._ +import code.model.dataAccess.BankAccountRouting +import code.regulatedentities.RegulatedEntityX +import code.regulatedentities.attribute.RegulatedEntityAttributeX +import com.openbankproject.commons.model.enums.RegulatedEntityAttributeType +import code.setup.{APIResponse, DefaultUsers, OBPReq} +import code.views.Views +import com.openbankproject.commons.model.{RegulatedEntityId, ViewId} +import com.openbankproject.commons.model.enums.{AccountRoutingScheme, PaymentServiceTypes, TransactionRequestTypes} +import net.liftweb.mapper.By +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Signing basket requests with the Berlin Group request signature enforced. + * + * The test setup runs with berlin_group_mandatory_headers empty and no certificate check, so nothing + * else exercises the signature, the mandatory headers or the PSP role on the signing basket routes. + * Here the TPP's certificate is registered as a regulated entity, as it would be at an ASPSP, and every + * call is signed. + */ +class SigningBasketSignedRequestTest extends BerlinGroupServerSetupV1_3 with PSD2SigningTestSupport with DefaultUsers { + object SBS extends Tag("Signing Baskets Service (SBS)") + + override protected def tppCommonName: String = "Signing Basket Test TPP" + + private val mandatoryHeaders = "Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID" + + private def enforceSignatures(): Unit = + setPropsValues( + "berlin_group_mandatory_headers" -> mandatoryHeaders, + "requirePsd2Certificates" -> "ONLINE", + // The generated certificate is self-signed, so it cannot pass chain validation. + "bypass_tpp_signature_validation" -> "true", + "suggested_default_sca_method" -> "DUMMY" + ) + + /** Registers the TPP's certificate as a regulated entity that holds the given PSD2 roles. */ + private def registerTpp(roles: String): Unit = { + val certificate = getCertificateData.getOrElse(fail("no test certificate")) + val entity = RegulatedEntityX.regulatedEntityProvider.vend.createRegulatedEntity( + Some("test-ca"), Some(certificate.certificatePem), Some(tppCommonName), Some(s"PSDDE-TEST-${certificate.serialNumber}"), + Some("PSD_PI"), Some("Test Street 1"), Some("Munich"), Some("80331"), Some("DE"), Some("https://tpp.example.com"), Some(roles) + ).openOrThrowException("the regulated entity must be created") + List("CERTIFICATE_SERIAL_NUMBER" -> certificate.serialNumber.toString, "CERTIFICATE_CA_NAME" -> tppCommonName).foreach { case (name, value) => + Await.result( + RegulatedEntityAttributeX.regulatedEntityAttributeProvider.vend.createOrUpdateRegulatedEntityAttribute( + RegulatedEntityId(entity.entityId), None, name, RegulatedEntityAttributeType.STRING, value, Some(true)), + 10.seconds).openOrThrowException("the attribute must be created") + } + } + + // PSD2SigningSupport builds its signer once, from the certificate of the first test to run, while + // PSD2SigningTestSupport generates a new certificate before every test. Signing with that shared signer + // would present a certificate other than the one registered here, so each call builds its own. + private def sign(body: String): Map[String, String] = + new PSD2RequestSigner(berlinGroupPrivateKey, berlinGroupCertificate, berlinGroupKeyId).signRequest(body) + + private def signedPost(request: OBPReq, body: String): APIResponse = + makePostRequestAdditionalHeader(request <@ (user1), body, sign(body).toList) + + private def lodgeSignedPayment(): String = { + val ibans = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + val (from, to) = (ibans.head, ibans.last) + Views.views.vend.systemView(ViewId(SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)).foreach(view => + Views.views.vend.grantAccessToSystemView(from.bankId, from.accountId, view, resourceUser1)) + val response = signedPost( + V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString, + s"""{"debtorAccount":{"iban":"${from.accountRouting.address}"},"instructedAmount":{"currency":"EUR","amount":"2001"}, + |"creditorAccount":{"iban":"${to.accountRouting.address}"},"creditorName":"TestCreditor"}""".stripMargin) + withClue(s"lodging a signed payment: ${response.body}: ") { response.code should equal(201) } + response.body.extract[InitiatePaymentResponseJson].paymentId + } + + private val basketBody = (paymentIds: List[String]) => s"""{"paymentIds":[${paymentIds.map(id => s""""$id"""").mkString(",")}]}""" + + feature("signing baskets with the request signature enforced") { + scenario("a basket request without the mandatory headers is refused before it reaches the basket", SBS) { + enforceSignatures() + val response = makePostRequest((V1_3_BG / "signing-baskets").POST <@ (user1), basketBody(List("any"))) + response.code should equal(400) + } + + scenario("a signed request from a certificate that is not registered is refused (CERTIFICATE_BLOCKED)", SBS) { + enforceSignatures() + val response = signedPost(V1_3_BG / "signing-baskets", basketBody(List("any"))) + response.code should equal(401) + response.body.extract[ErrorMessagesBG].tppMessages.head.code should equal("CERTIFICATE_BLOCKED") + } + + scenario("a TPP holding the payment initiation role creates a basket of its own signed payment", SBS) { + enforceSignatures() + registerTpp("PSP_PI") + val payment = lodgeSignedPayment() + val response = signedPost(V1_3_BG / "signing-baskets", basketBody(List(payment))) + response.code should equal(201) + val basketId = response.body.extract[SigningBasketResponseJson].basketId + Option(response.headers.getOrElse(fail("no headers")).get("Location")).getOrElse(fail("Location is missing")) should endWith(s"/signing-baskets/$basketId") + Option(response.headers.get.get("ASPSP-SCA-Approach")) should not be empty + } + + scenario("a TPP holding only the account information role cannot create a basket of payments (ROLE_INVALID)", SBS) { + enforceSignatures() + registerTpp("PSP_AI") + val response = signedPost(V1_3_BG / "signing-baskets", basketBody(List("any"))) + withClue(s"${response.body}: ") { response.code should equal(403) } + response.body.extract[ErrorMessagesBG].tppMessages.head.code should equal("ROLE_INVALID") + } + } +} From db079c4e1409751c603ad81fa581c97351053cf7 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 23:15:36 +0200 Subject: [PATCH 18/40] docs: list the errors a signing basket operation can answer with Add the new basket errors to each operation's ResourceDoc, and correct a typo in the delete description. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index f4a3a8ddbc..8a59c95736 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -181,7 +181,7 @@ The resource identifications of these transactions are contained in the payload "transactionStatus" : "RCVD", "psuMessage" : { } }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketMemberMixInvalid, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(createSigningBasket) ) @@ -225,14 +225,16 @@ The resource identifications of these transactions are contained in the payload "Delete the signing basket", s"""${mockedDataText(false)} Delete the signing basket structure as long as no (partial) authorisation has yet been applied. -The undlerying transactions are not affected by this deletion. +The underlying transactions are not affected by this deletion. + +Only the TPP that created the basket may delete it. A basket that is already cancelled answers 204 again. Remark: The signing basket as such is not deletable after a first (partial) authorisation has been applied. Nevertheless, single transactions might be cancelled on an individual basis on the XS2A interface. """, EmptyBody, EmptyBody, - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, SigningBasketStatusInvalid, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(deleteSigningBasket) ) @@ -264,7 +266,7 @@ Returns the content of an signing basket object.""", "payments" : "", "consents" : "" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(getSigningBasket) ) @@ -298,7 +300,7 @@ This function returns an array of hyperlinks to all generated authorisation sub- JvalueCaseClass(json.parse("""{ "authorisationIds" : "" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(getSigningBasketAuthorisation) ) @@ -330,7 +332,7 @@ This method returns the SCA status of a signing basket's authorisation sub-resou JvalueCaseClass(json.parse("""{ "scaStatus" : "psuAuthenticated" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, SigningBasketAuthorisationNotFound, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(getSigningBasketScaStatus) ) @@ -361,7 +363,7 @@ Returns the status of a signing basket object. JvalueCaseClass(json.parse("""{ "transactionStatus" : "RCVD" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(getSigningBasketStatus) ) @@ -454,7 +456,7 @@ This applies in the following scenarios: } } }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketNotFound, SigningBasketStatusInvalid, SigningBasketAuthorisationVariantNotSupported, BerlinGroupPsuNotIdentified, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(startSigningBasketAuthorisation) ) @@ -620,7 +622,7 @@ There are the following request types on this access path: } } }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketNotFound, SigningBasketAuthorisationNotFound, SigningBasketAuthorisationVariantNotSupported, SigningBasketAuthorisationDisabled, SigningBasketConsentNotSupported, SigningBasketStatusInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, InvalidChallengeAnswer, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(updateSigningBasketPsuData) ) From 165ad3a6869e6b61b81ad1d99fe428ffa1266362 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 5 Oct 2026 23:17:15 +0200 Subject: [PATCH 19/40] test: keep setPropsValues out of expression-bodied helpers The test-isolation lint reads a def without braces as class-body code and rejects setPropsValues in it. Give the two signing basket helpers braces. --- .../api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala | 3 ++- .../api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 6cec52677b..9d498dae19 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -168,8 +168,9 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } /** Tests that answer an SCA need a challenge whose answer is known, and an instance that lets baskets be authorised. */ - private def enableBasketAuthorisation(): Unit = + private def enableBasketAuthorisation(): Unit = { setPropsValues("suggested_default_sca_method" -> "DUMMY", "signing_basket_authorisation_enabled" -> "true") + } // What the database says, as opposed to what an HTTP response claims. private def storedBasketStatus(basketId: String): Option[String] = diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala index f8b7989f1d..878266ad0a 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala @@ -60,7 +60,7 @@ class SigningBasketSignedRequestTest extends BerlinGroupServerSetupV1_3 with PSD private val mandatoryHeaders = "Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID" - private def enforceSignatures(): Unit = + private def enforceSignatures(): Unit = { setPropsValues( "berlin_group_mandatory_headers" -> mandatoryHeaders, "requirePsd2Certificates" -> "ONLINE", @@ -68,6 +68,7 @@ class SigningBasketSignedRequestTest extends BerlinGroupServerSetupV1_3 with PSD "bypass_tpp_signature_validation" -> "true", "suggested_default_sca_method" -> "DUMMY" ) + } /** Registers the TPP's certificate as a regulated entity that holds the given PSD2 roles. */ private def registerTpp(roles: String): Unit = { From e8e3804533128ce49a69dd3544656ee23bfbf663 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 07:36:49 +0200 Subject: [PATCH 20/40] fix: declare an attribution policy for the signing basket PSU column UserReferenceAttributionPolicyTest requires every column that looks like a user id to be named in UserReference. MappedSigningBasket.PsuUserId was not. The PSU is resolved explicitly, by Consent.resolveBerlinGroupPsu, from the bound PSU, a genuine PSU session or the PSU-ID header; the calling agent is never stored and a client-credentials TPP's pseudo-user is never written. Nothing is delegated, so there is nothing to look up, which is the UseAuthenticatedUserId policy, as for the signing basket challenge's expected user. --- obp-api/src/main/scala/code/users/UserReference.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/obp-api/src/main/scala/code/users/UserReference.scala b/obp-api/src/main/scala/code/users/UserReference.scala index 76ad8e9613..d128eb8c4f 100644 --- a/obp-api/src/main/scala/code/users/UserReference.scala +++ b/obp-api/src/main/scala/code/users/UserReference.scala @@ -151,6 +151,7 @@ object UserReference { case object Entitlement_GrantedByUserId extends UserReference(UseAuthenticatedUserId, "code.entitlement.MappedEntitlement", List("mGrantedByUserId"), "audit: who granted") case object UserLocks_UserId extends UserReference(UseAuthenticatedUserId, "code.userlocks.UserLocks", List("UserId"), "lock the authenticated user") case object ExpectedChallengeAnswer_ExpectedUserId extends UserReference(UseAuthenticatedUserId, "code.transactionChallenge.MappedExpectedChallengeAnswer", List("ExpectedUserId"), "consent and signing-basket authorisation: the caller IS the person authorising, so the challenge is theirs") + case object SigningBasket_PsuUserId extends UserReference(UseAuthenticatedUserId, "code.signingbaskets.MappedSigningBasket", List("PsuUserId"), "the PSU the basket is for, resolved explicitly by Consent.resolveBerlinGroupPsu from the bound PSU, a genuine PSU session or PSU-ID; never the calling agent, and a client-credentials TPP's pseudo-user is never stored. Nothing is delegated, so there is nothing to look up") case object ChatMessage_SenderUserId extends UserReference(UseAuthenticatedUserId, "code.chat.ChatMessage", List("SenderUserId"), "sender = the authenticated user is truthful") case object Metric_UserId extends UserReference(UseAuthenticatedUserId, "code.metrics.MappedMetric", List("userId"), "record both: on-behalf-of via consent_reference_id at read time") case object MetricArchive_UserId extends UserReference(UseAuthenticatedUserId, "code.metrics.MetricArchive", List("userId"), "as Metric_UserId") @@ -244,6 +245,7 @@ object UserReference { Entitlement_GrantedByUserId, UserLocks_UserId, ExpectedChallengeAnswer_ExpectedUserId, + SigningBasket_PsuUserId, ExpectedChallengeAnswer_ExpectedUserId_TransactionRequest, ChatMessage_SenderUserId, Metric_UserId, From 3e5417e1f03f44f8ad091beab4892ba183963e79 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:04:32 +0200 Subject: [PATCH 21/40] feat: record how each member of a signing basket was executed Executing a basket's authorisation touches several payments and, later, consents, and the standard's basket statuses cannot say that the first payment was booked and the second refused. Keep that per member instead, in a new table with one row per member, unique on (basket, type, member): PENDING, EXECUTING, DONE, FAILED or UNKNOWN, a detail, and the number of attempts. Every move is one conditional update from the states the caller names, so two executors reaching for the same member have exactly one winner, and a claim counts as an attempt. A member left EXECUTING past a lease becomes UNKNOWN, because nothing records whether it took effect. A second stored basket status, EXECUTION_INCOMPLETE, is reported as RCVD like AUTHORISING. Nothing uses these yet. --- .../main/scala/bootstrap/liftweb/Boot.scala | 3 +- .../code/api/berlin/group/ConstantsBG.scala | 9 ++- .../MappedSigningBasketProvider.scala | 81 +++++++++++++++++++ .../code/signingbaskets/SigningBasket.scala | 59 ++++++++++++++ .../MappedSigningBasketProviderTest.scala | 70 ++++++++++++++++ .../scala/code/util/MappedClassNameTest.scala | 1 + 6 files changed, 221 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 41b8a29838..419ac5a231 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -125,7 +125,7 @@ import code.regulatedentities.attribute.RegulatedEntityAttribute import code.counterpartyattribute.{CounterpartyAttribute => CounterpartyAttributeMapper} import code.scheduler._ import code.scope.{MappedScope, MappedUserScope, Scope} -import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketMemberClaim, MappedSigningBasketPayment} +import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketMemberClaim, MappedSigningBasketMemberExecution, MappedSigningBasketPayment} import code.socialmedia.MappedSocialMedia import code.standingorders.StandingOrder import code.taxresidence.MappedTaxResidence @@ -1005,6 +1005,7 @@ object ToSchemify extends MdcLoggable { MappedSigningBasketPayment, MappedSigningBasketConsent, MappedSigningBasketMemberClaim, + MappedSigningBasketMemberExecution, MappedRegulatedEntity, AtmAttribute, AbacRule, diff --git a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala index cc048cc33b..ee929d7bcc 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala @@ -55,7 +55,14 @@ object ConstantsBG { */ val AUTHORISING_INTERNAL = "AUTHORISING" + /** + * Stored when the authorisation was answered correctly but not every member took effect: a payment + * failed, or an outcome is not known. Reported as RCVD, like AUTHORISING. The members' own results say + * what happened to each. + */ + val EXECUTION_INCOMPLETE_INTERNAL = "EXECUTION_INCOMPLETE" + def external(storedStatus: String): String = - if (storedStatus == AUTHORISING_INTERNAL) RCVD.toString else storedStatus + if (storedStatus == AUTHORISING_INTERNAL || storedStatus == EXECUTION_INCOMPLETE_INTERNAL) RCVD.toString else storedStatus } } diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index 01bbdf5b02..73390f42e6 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -103,6 +103,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } if (result.isEmpty) created.foreach { basket => tryo { + MappedSigningBasketMemberExecution.bulkDelete_!!(By(MappedSigningBasketMemberExecution.BasketId, basket.basketId)) MappedSigningBasketMemberClaim.bulkDelete_!!(By(MappedSigningBasketMemberClaim.BasketId, basket.basketId)) MappedSigningBasketPayment.bulkDelete_!!(By(MappedSigningBasketPayment.BasketId, basket.basketId)) MappedSigningBasketConsent.bulkDelete_!!(By(MappedSigningBasketConsent.BasketId, basket.basketId)) @@ -115,6 +116,70 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } } + override def createSigningBasketMemberExecutions(basketId: String, members: List[(String, String)]): Box[Boolean] = + tryo { + DB.use(DefaultConnectionIdentifier) { _ => + members.zipWithIndex.foreach { case ((memberType, memberId), position) => + val exists = MappedSigningBasketMemberExecution.find( + By(MappedSigningBasketMemberExecution.BasketId, basketId), + By(MappedSigningBasketMemberExecution.MemberType, memberType), + By(MappedSigningBasketMemberExecution.MemberId, memberId)).isDefined + if (!exists) + MappedSigningBasketMemberExecution.create + .BasketId(basketId).MemberType(memberType).MemberId(memberId) + .Position(position).State(SigningBasketMemberState.Pending).Detail("").Attempts(0) + .saveMe() + } + } + true + } + + override def getSigningBasketMemberExecutions(basketId: String): List[SigningBasketMemberExecution] = + MappedSigningBasketMemberExecution + .findAll(By(MappedSigningBasketMemberExecution.BasketId, basketId), OrderBy(MappedSigningBasketMemberExecution.Position, Ascending)) + .map(row => SigningBasketMemberExecution( + row.MemberType.get, row.MemberId.get, row.Position.get, row.State.get, Option(row.Detail.get).getOrElse(""), row.Attempts.get)) + + override def transitionSigningBasketMemberExecution(basketId: String, + memberType: String, + memberId: String, + from: Set[String], + to: String, + detail: String): Box[Boolean] = + tryo { + val m = MappedSigningBasketMemberExecution + val fromList = from.toList + // A claim is the move to EXECUTING, and counts as an attempt. + val attemptsSql = if (to == SigningBasketMemberState.Executing) s", ${m.Attempts._dbColumnNameLC} = ${m.Attempts._dbColumnNameLC} + 1" else "" + DB.runUpdate( + s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + + s"${m.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP$attemptsSql " + + s"WHERE ${m.BasketId._dbColumnNameLC} = ? AND ${m.MemberType._dbColumnNameLC} = ? AND ${m.MemberId._dbColumnNameLC} = ? " + + s"AND ${m.State._dbColumnNameLC} IN (${fromList.map(_ => "?").mkString(", ")})", + List[Any](to, detail.take(2000), basketId, memberType, memberId) ++ fromList) == 1 + } + + override def markStaleSigningBasketMembersUnknown(olderThanSeconds: Long): Box[Int] = + tryo { + val m = MappedSigningBasketMemberExecution + val cutoff = new java.sql.Timestamp(System.currentTimeMillis() - olderThanSeconds * 1000) + DB.runUpdate( + s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + + s"${m.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"WHERE ${m.State._dbColumnNameLC} = ? AND ${m.updatedAt._dbColumnNameLC} < ?", + List[Any](SigningBasketMemberState.Unknown, "The executor stopped before recording an outcome", SigningBasketMemberState.Executing, cutoff)) + } + + override def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] = { + val cutoff = new java.util.Date(System.currentTimeMillis() - olderThanSeconds * 1000) + MappedSigningBasket.findAll( + ByList(MappedSigningBasket.Status, List(ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL)), + BySql[MappedSigningBasket](s"${MappedSigningBasket.updatedAt._dbColumnNameLC} < ?", IHaveValidatedThisSQL("signing-basket", "2026-10-06"), cutoff), + OrderBy(MappedSigningBasket.updatedAt, Ascending), + MaxRows(limit) + ).map(_.basketId) + } + override def releaseSigningBasketMembers(basketId: String): Box[Boolean] = tryo { MappedSigningBasketMemberClaim.bulkDelete_!!(By(MappedSigningBasketMemberClaim.BasketId, basketId)) } @@ -208,3 +273,19 @@ object MappedSigningBasketMemberClaim extends MappedSigningBasketMemberClaim wit override def dbTableName = "SigningBasketMemberClaim" override def dbIndexes = UniqueIndex(MemberKey) :: Index(BasketId) :: super.dbIndexes } + +/** Per member, how executing the basket's authorisation went. See SigningBasketMemberExecution. */ +class MappedSigningBasketMemberExecution extends LongKeyedMapper[MappedSigningBasketMemberExecution] with IdPK with CreatedUpdated { + override def getSingleton = MappedSigningBasketMemberExecution + object BasketId extends MappedUUID(this) + object MemberType extends MappedString(this, 16) + object MemberId extends MappedString(this, 255) + object Position extends MappedInt(this) + object State extends MappedString(this, 16) + object Detail extends MappedString(this, 2000) + object Attempts extends MappedInt(this) +} +object MappedSigningBasketMemberExecution extends MappedSigningBasketMemberExecution with LongKeyedMetaMapper[MappedSigningBasketMemberExecution] { + override def dbTableName = "SigningBasketMemberExecution" + override def dbIndexes = UniqueIndex(BasketId, MemberType, MemberId) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index 7154f7b72c..d3e7602ece 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -38,6 +38,38 @@ object SigningBasketX extends SimpleInjector { private def buildOne: SigningBasketProvider = MappedSigningBasketProvider } +/** + * What happened to one member of a basket when the basket's authorisation was executed. + * + * `state` is one of SigningBasketMemberState. `detail` says why for FAILED and UNKNOWN. The state is + * the member's own and is never folded into the basket's status: a basket can be RCVD while its + * first payment is DONE and its second FAILED, and the TPP reads that here. + */ +case class SigningBasketMemberExecution( + memberType: String, + memberId: String, + position: Int, + state: String, + detail: String, + attempts: Int +) + +object SigningBasketMemberState { + /** Not started. */ + val Pending = "PENDING" + /** Claimed by one executor; the outcome is not known yet. */ + val Executing = "EXECUTING" + /** Booked (a payment) or activated (a consent), and recorded as such. */ + val Done = "DONE" + /** Refused or failed before it took effect. Safe to try again. */ + val Failed = "FAILED" + /** The executor stopped without recording an outcome. Whether it took effect is not known. */ + val Unknown = "UNKNOWN" + + val PaymentType = "payment" + val ConsentType = "consent" +} + trait SigningBasketProvider extends MdcLoggable { def getSigningBaskets(): List[SigningBasketTrait] @@ -57,6 +89,33 @@ trait SigningBasketProvider extends MdcLoggable { psuUserId: Option[String] ): Box[SigningBasketTrait] + /** Records each member as PENDING, in the order given. Members already recorded are left as they are. */ + def createSigningBasketMemberExecutions(basketId: String, members: List[(String, String)]): Box[Boolean] + + /** The members of a basket with their execution state, in the order they were recorded. */ + def getSigningBasketMemberExecutions(basketId: String): List[SigningBasketMemberExecution] + + /** + * Moves one member from one of the given states to another, only if it is still in one of them. + * One conditional update, so two executors reaching for the same member have exactly one winner. + * `attempts` goes up each time a member is claimed (moved to EXECUTING). + */ + def transitionSigningBasketMemberExecution(basketId: String, + memberType: String, + memberId: String, + from: Set[String], + to: String, + detail: String): Box[Boolean] + + /** + * Members still EXECUTING after `olderThanSeconds` belong to an executor that stopped. They become + * UNKNOWN, because nothing records whether they took effect. Returns how many were moved. + */ + def markStaleSigningBasketMembersUnknown(olderThanSeconds: Long): Box[Int] + + /** Baskets whose execution has not finished, oldest first: AUTHORISING or EXECUTION_INCOMPLETE. */ + def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] + /** * Frees the payments and consents a basket was holding, so they can join another basket. Called when * a basket reaches a final status. diff --git a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala index bb2444f1e1..a5d56a6944 100644 --- a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -158,4 +158,74 @@ class MappedSigningBasketProviderTest extends ServerSetup { } } } + + feature("each member of a basket has its own execution state") { + import SigningBasketMemberState._ + + scenario("members are recorded PENDING in the order given, once") { + val basket = newBasket() + val (first, second, consent) = (uuid(), uuid(), uuid()) + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, first), (PaymentType, second), (ConsentType, consent))) + .openOrThrowException("x") should be(true) + // Recording them again changes nothing. + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, second), (PaymentType, first))) + provider.getSigningBasketMemberExecutions(basket.basketId).map(m => (m.memberType, m.memberId, m.state, m.attempts)) should equal( + List((PaymentType, first, Pending, 0), (PaymentType, second, Pending, 0), (ConsentType, consent, Pending, 0))) + } + + scenario("a member moves only from a state the caller names, and a claim counts as an attempt") { + val basket = newBasket() + val payment = uuid() + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, payment))) + def move(from: Set[String], to: String, detail: String = "") = + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, payment, from, to, detail).openOrThrowException("x") + move(Set(Executing), Done) should be(false) + move(Set(Pending, Failed), Executing) should be(true) + move(Set(Pending, Failed), Executing) should be(false) + move(Set(Executing), Failed, "no funds") should be(true) + move(Set(Pending, Failed), Executing) should be(true) + move(Set(Executing), Done) should be(true) + val stored = provider.getSigningBasketMemberExecutions(basket.basketId).head + (stored.state, stored.attempts) should equal((Done, 2)) + } + + scenario("executors racing for one member have exactly one winner") { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 10).foreach { round => + val basket = newBasket() + val payment = uuid() + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, payment))) + val executors = (1 to 8).map(_ => Future( + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, payment, Set(Pending), Executing, "").openOr(false))) + withClue(s"round $round: ") { + Await.result(Future.sequence(executors), 60.seconds).count(identity) should equal(1) + provider.getSigningBasketMemberExecutions(basket.basketId).head.attempts should equal(1) + } + } + } + + scenario("a member still EXECUTING after the lease becomes UNKNOWN; one that finished does not") { + val basket = newBasket() + val (stuck, finished) = (uuid(), uuid()) + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, stuck), (PaymentType, finished))) + List(stuck, finished).foreach(id => provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, id, Set(Pending), Executing, "")) + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, finished, Set(Executing), Done, "") + Thread.sleep(1200) + provider.markStaleSigningBasketMembersUnknown(1).openOrThrowException("x") should be >= 1 + provider.getSigningBasketMemberExecutions(basket.basketId).map(m => m.memberId -> m.state).toMap should equal( + Map(stuck -> Unknown, finished -> Done)) + } + + scenario("baskets whose execution has not finished are listed, oldest first") { + val stuck = newBasket() + val done = newBasket() + provider.transitionSigningBasketStatus(stuck.basketId, "RCVD", code.api.berlin.group.ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL) + provider.transitionSigningBasketStatus(done.basketId, "RCVD", "ACTC") + Thread.sleep(1200) + val awaiting = provider.getSigningBasketsAwaitingExecution(1, 100) + awaiting should contain(stuck.basketId) + awaiting should not contain done.basketId + provider.getSigningBasketsAwaitingExecution(3600, 100) should not contain stuck.basketId + } + } } diff --git a/obp-api/src/test/scala/code/util/MappedClassNameTest.scala b/obp-api/src/test/scala/code/util/MappedClassNameTest.scala index 3fb9280aa8..b80f0f4549 100644 --- a/obp-api/src/test/scala/code/util/MappedClassNameTest.scala +++ b/obp-api/src/test/scala/code/util/MappedClassNameTest.scala @@ -145,6 +145,7 @@ class MappedClassNameTest extends FeatureSpec { "code.signingbaskets.MappedSigningBasket", "code.signingbaskets.MappedSigningBasketPayment", "code.signingbaskets.MappedSigningBasketMemberClaim", + "code.signingbaskets.MappedSigningBasketMemberExecution", "code.CustomerDependants.MappedCustomerDependant", ) From d3d41b326efa69e97a87f86a72d8c87f8582bdd8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:14:42 +0200 Subject: [PATCH 22/40] feat: book a signing basket's payments in order and record each outcome Answering a basket's authorisation started the booking of its payments without waiting for it and wrote ACTC at once, so the response could say 200 and ACTC while nothing moved. Replace that with an execution service: - each payment is claimed with a conditional update before it is touched; - it is booked through the same connector call the payment routes use, and the call is awaited, so the money has moved when the response is sent; - a payment that already carries a transaction id is never booked again, which makes a repeat or a resumption safe on the mapped connector; - the first member that does not finish stops the run; - the basket becomes ACTC only when every member is DONE, and the members are then released. Otherwise it is stored EXECUTION_INCOMPLETE and reported as RCVD, and each member's own state says what happened; - a failed payment is retried, a limited number of times, only on the mapped connector. On another connector a failure is recorded UNKNOWN, since the connector may have booked before it failed; - a member left UNKNOWN is reconciled by its transaction id, otherwise it is left for an operator. The response keeps scaStatus finalised, the SCA having succeeded, with a message to the PSU when not every payment could be executed. Several payments are still not one transaction: a failure leaves the earlier ones booked. --- .../resources/props/sample.props.template | 12 +- .../v1_3/Http4sBGv13SigningBaskets.scala | 26 +-- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 8 +- .../group/v1_3/SigningBasketExecution.scala | 184 ++++++++++++++++++ .../v1_3/SigningBasketServiceSBSApiTest.scala | 125 ++++++++++-- 5 files changed, 313 insertions(+), 42 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 821b537311..ebfeb1dae7 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1676,12 +1676,16 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER #berlin_group_aspsp_sca_approach = redirect # Whether a Berlin Group signing basket may be authorised (PUT /signing-baskets/{basketId}/authorisations/{authorisationId}). -# Default false: the call answers 403 SERVICE_BLOCKED. Answering the authorisation of a basket starts the -# booking of its payments without waiting for the result, so a basket can report itself authorised while a -# payment was not booked. Turn this on only once that has been replaced. Creating, reading, starting an -# authorisation on and deleting baskets are not affected. +# Default false: the call answers 403 SERVICE_BLOCKED. Answering the authorisation books the basket's payments +# one after another and records, per payment, whether it was booked. Roll this out in stages: first the ownership +# guard, then this, with a recovery rehearsal in between. Creating, reading, starting an authorisation on and +# deleting baskets are not affected. #signing_basket_authorisation_enabled = false +# How many times a payment of a signing basket that failed to book is claimed again, on the mapped connector only. +# A payment on any other connector whose booking failed is left UNKNOWN for an operator. +#signing_basket_member_max_attempts = 3 + # Support multiple brands on one instance. Note this needs checking on a clustered environment #brands_enabled=false diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 8a59c95736..e4797d0835 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -41,7 +41,7 @@ import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps import code.api.util.newstyle.SigningBasketNewStyle import code.api.util.newstyle.SigningBasketNewStyle.{AuthorisationOperation, CreatorOnly} import code.bankconnectors.Connector -import code.signingbaskets.SigningBasketX +import code.signingbaskets.{SigningBasketMemberState, SigningBasketX} import code.util.Helper.{MdcLoggable, booleanToFuture} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -471,21 +471,6 @@ This applies in the following scenarios: else if (message.contains("OBP-40016") || message.contains("OBP-20211") || message.contains("OBP-40014")) (message, 401) else (message, 400) - /** - * Starts the booking of each payment and marks it completed. Neither is awaited and neither outcome - * is read, which is the defect this leaves in place: replacing it is the payment execution work and - * is not part of this change. What this change does guarantee is who gets here -- only the basket's - * owner, with a correct answer to an authorisation of this basket, and only once, since the basket is - * claimed before this runs. That is also why `signing_basket_authorisation_enabled` is off by default. - */ - private def startPaymentExecution(paymentIds: List[String], callContext: Option[CallContext]): Unit = - paymentIds.foreach { paymentId => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(paymentId), COMPLETED.toString, callContext) - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext).map { t => - Connector.connector.vend.makePaymentV400(t._1, None, callContext) - } - } - // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── // // Order matters, and nothing may be changed until the answer has been checked: @@ -554,12 +539,11 @@ This applies in the following scenarios: claimed <- Future(provider.transitionSigningBasketStatus( basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(claimed.openOr(false)) - _ = startPaymentExecution(paymentIds, callContext) - _ <- Future(provider.transitionSigningBasketStatus( - basketId, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.ACTC.toString)) - _ <- Future(provider.releaseSigningBasketMembers(basketId)) + // Each member is recorded, then booked in order. The basket becomes ACTC only if every one is. + _ <- Future(provider.createSigningBasketMemberExecutions(basketId, paymentIds.map(SigningBasketMemberState.PaymentType -> _))) + allDone <- SigningBasketExecution.execute(basketId, callContext) } yield { - JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge) + JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge, executionIncomplete = !allDone) } } } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index 8786c2d6c8..6e2f15f197 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -894,10 +894,14 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ } /** The 200 answer to a transaction authorisation on a signing basket: a scaStatusResponse whose link names the basket's authorisation. */ - def createUpdateSigningBasketPsuDataJson(basketId: String, challenge: ChallengeTrait) = { + def createUpdateSigningBasketPsuDataJson(basketId: String, challenge: ChallengeTrait, executionIncomplete: Boolean = false) = { ScaStatusResponse( scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""), - psuMessage = Some("Please check your SMS at a mobile device."), + // The authorisation itself succeeded either way. When not every payment could be executed, the basket + // stays RCVD and each payment's own result says what happened. + psuMessage = Some( + if (executionIncomplete) "The authorisation was accepted, but not every payment in the basket could be executed yet." + else "Please check your SMS at a mobile device."), _links = Some(LinksAll(scaStatus = Some(HrefType(Some( s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}"))))) ) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala new file mode 100644 index 0000000000..a94bceb371 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -0,0 +1,184 @@ +/** +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.berlin.group.v1_3 + +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.getPropsAsIntValue +import code.api.util.{CallContext, NewStyle} +import code.signingbaskets.{SigningBasketMemberExecution, SigningBasketMemberState, SigningBasketX} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{AccountId, BankAccount, BankId, TransactionRequest, TransactionRequestId} +import net.liftweb.common.Full + +import scala.concurrent.Future +import scala.util.{Failure, Success} + +/** + * Carries out a signing basket's authorisation once its SCA has been answered: books each payment, + * one after another, and records what happened to each. + * + * What it guarantees, and what it does not. + * + * - Each member is claimed with one conditional update before it is touched, so two executors (the + * request that answered the SCA and a later resumption) never work on the same member at once. + * - A payment that already carries a transaction id is never booked again. The payment id is the + * idempotency key: this is what makes a resumption safe. It holds for the mapped connector, which + * records the transaction id in the same database as the booking. + * - An execution that stopped part way is resumed from where it stopped. A member left EXECUTING past + * the lease is UNKNOWN: it is reconciled by the transaction id if it has one, and otherwise left for + * an operator, never retried blindly. + * - A member that failed is retried automatically only on the mapped connector, and only a limited + * number of times. On any other connector a failure is recorded as UNKNOWN, because the connector + * may have booked before it failed, and nothing here can ask it. + * - The basket reaches ACTC only when every member is DONE. Otherwise it is EXECUTION_INCOMPLETE, + * reported as RCVD, and the members' own results say what happened. + * + * It does not make several payments atomic. Each is booked by its connector on its own, so a failure + * leaves the earlier ones booked, which is what the per-member results are there to show. + */ +object SigningBasketExecution extends MdcLoggable { + + import SigningBasketMemberState._ + + private def provider = SigningBasketX.signingBasketProvider.vend + + /** How many times a member that failed is claimed again before it is left for an operator. */ + private def maxAttempts: Int = getPropsAsIntValue("signing_basket_member_max_attempts", 3) + + private val awaitingAuthorisation = "RCVD" + + /** + * Executes the basket's members that are not yet DONE, in order, stopping at the first that does not + * finish. Returns true when every member is DONE and the basket has become ACTC. + */ + def execute(basketId: String, callContext: Option[CallContext]): Future[Boolean] = { + def loop(rest: List[SigningBasketMemberExecution]): Future[Boolean] = rest match { + case Nil => Future.successful(true) + case member :: tail if member.state == Done => loop(tail) + case member :: tail => executeMember(basketId, member, callContext).flatMap(done => if (done) loop(tail) else Future.successful(false)) + } + loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) + } + + private def finish(basketId: String, allDone: Boolean): Future[Boolean] = Future { + val authorising = ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL + val incomplete = ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL + val actc = ConstantsBG.SigningBasketsStatus.ACTC.toString + if (allDone) { + val completed = provider.transitionSigningBasketStatus(basketId, authorising, actc).openOr(false) || + provider.transitionSigningBasketStatus(basketId, incomplete, actc).openOr(false) + // The members are free to join another basket only once the basket is final. + if (completed) provider.releaseSigningBasketMembers(basketId) + completed + } else { + provider.transitionSigningBasketStatus(basketId, authorising, incomplete) + false + } + } + + private def executeMember(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = + member.memberType match { + case PaymentType => executePayment(basketId, member, callContext) + // Activating a consent is not available yet, so a basket holding one is not executed. + case _ => record(basketId, member, Set(Pending, Failed), Failed, "Activating a consent through a signing basket is not available").map(_ => false) + } + + private def record(basketId: String, member: SigningBasketMemberExecution, from: Set[String], to: String, detail: String): Future[Boolean] = Future { + provider.transitionSigningBasketMemberExecution(basketId, member.memberType, member.memberId, from, to, detail).openOr(false) + } + + /** The states a member may be claimed from: a first attempt, or a retry that is allowed. */ + private def claimableFrom(member: SigningBasketMemberExecution): Set[String] = + if (member.attempts > 0 && member.attempts < maxAttempts) Set(Pending, Failed) else Set(Pending) + + private def bookedTransactionIds(transactionRequest: TransactionRequest): Boolean = + Option(transactionRequest.transaction_ids).exists(_.trim.nonEmpty) + + private def executePayment(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = { + def finishWith(to: String, detail: String): Future[Boolean] = + record(basketId, member, Set(Executing, Unknown), to, detail).map(_ => to == Done) + + if (member.state == Unknown) reconcile(basketId, member, callContext) + else record(basketId, member, claimableFrom(member), Executing, "").flatMap { + // Another executor holds it, or it is Failed past its attempts: not this one's to do. + case false => Future.successful(false) + case true => + NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { + case Failure(_) => finishWith(Failed, "The payment cannot be read") + case Success((payment, _)) if bookedTransactionIds(payment) => + // Already booked, by an earlier attempt that did not get to record it. + finishWith(Done, s"Already booked: transaction ${payment.transaction_ids}") + case Success((payment, _)) if payment.status != awaitingAuthorisation => + finishWith(Failed, s"The payment is ${payment.status}, not waiting for authorisation") + case Success((payment, _)) => book(basketId, member, payment, callContext, finishWith) + } + } + } + + private def book(basketId: String, + member: SigningBasketMemberExecution, + payment: TransactionRequest, + callContext: Option[CallContext], + finishWith: (String, String) => Future[Boolean]): Future[Boolean] = + NewStyle.function.checkBankAccountExists(BankId(payment.from.bank_id), AccountId(payment.from.account_id), callContext) + .transform(Success(_)).flatMap { + case Failure(_) => finishWith(Failed, "The debtor account cannot be found") + case Success((fromAccount, _)) => + val mapped = isMappedConnector(fromAccount, payment, callContext) + NewStyle.function.createTransactionAfterChallengeV210(fromAccount, payment, callContext).transform(Success(_)).flatMap { + case Success(_) => finishWith(Done, "Booked") + case Failure(error) => + // The connector failed. Whether it booked first is read from the payment: a transaction id + // means it did. Without one, the mapped connector is treated as not having booked, so the + // payment can be tried again; any other connector may have, so it is left UNKNOWN. + NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { + case Success((after, _)) if bookedTransactionIds(after) => finishWith(Done, s"Booked: transaction ${after.transaction_ids}") + case _ if mapped => finishWith(Failed, s"Booking failed: ${Option(error.getMessage).getOrElse(error.getClass.getSimpleName)}") + case _ => finishWith(Unknown, s"The connector failed and may have booked: ${Option(error.getMessage).getOrElse(error.getClass.getSimpleName)}") + } + } + } + + /** A member left UNKNOWN is DONE if its payment carries a transaction id, and is otherwise left for an operator. */ + private def reconcile(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = + NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { + case Success((payment, _)) if bookedTransactionIds(payment) => + record(basketId, member, Set(Unknown), Done, s"Reconciled: transaction ${payment.transaction_ids}").map(_ => true) + case _ => Future.successful(false) + } + + /** Whether the connector that books this payment is the mapped one. Only it is retried automatically. */ + private def isMappedConnector(fromAccount: BankAccount, payment: TransactionRequest, callContext: Option[CallContext]): Boolean = + scala.util.Try { + code.bankconnectors.getConnectorNameAndMethodRouting( + "createTransactionAfterChallengeV210", + Array("fromAccount" -> fromAccount, "transactionRequest" -> payment, "callContext" -> callContext) + )._2 == "mapped" + }.getOrElse(false) +} diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 9d498dae19..9c8dfc2a3f 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -108,7 +108,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { * a payment in. A payment of 10 is booked on creation (ACCP) and can no longer be authorised by * anything. */ - private def lodgePayment(amount: String = "2001", as: Option[(Consumer, Token)] = user1, initiator: User = resourceUser1): String = { + private def lodgePayment(amount: String = "2001", as: Option[(Consumer, Token)] = user1, initiator: User = resourceUser1, creditorIban: Option[String] = None): String = { val ibanFrom = ibanAccounts.head val ibanTo = ibanAccounts.last Views.views.vend.systemView(ViewId(SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)).foreach(view => @@ -118,7 +118,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { s"""{ | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, | "instructedAmount": { "currency": "EUR", "amount": "$amount" }, - | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "creditorAccount": { "iban": "${creditorIban.getOrElse(ibanTo.accountRouting.address)}" }, | "creditorName": "TestCreditor" |}""".stripMargin val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (as) @@ -144,7 +144,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { private def createBasket(paymentIds: List[String], as: Option[(Consumer, Token)] = user1): String = { val response = postBasket(s"""{"paymentIds":${idList(paymentIds)}}""", as) - withClue(s"creating a basket of $paymentIds: ") { response.code should equal(201) } + withClue(s"creating a basket of $paymentIds: ${response.body}: ") { response.code should equal(201) } response.body.extract[SigningBasketResponseJson].basketId } @@ -831,25 +831,120 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } - // ───────────────────────── execution: S4 (the next phase, recorded here so it is not forgotten) ───────────────────────── + // ───────────────────────── execution: every member is booked, and what happened to each is recorded ───────────────────────── - feature("BG v1.3 signing baskets - a payment the basket reports as authorised is actually booked") { - // Ignored until payment execution is reworked (the next phase). Measured on the baseline: the final answer - // returns 200, the payment is stored COMPLETED and the basket ACTC, and neither account moves, even after - // eight seconds. The booking is started without being awaited and its outcome is never read. - ignore("S4 (target, execution phase): after the final answer the debtor account is debited and the creditor account credited", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + private def memberResults(basketId: String): List[(String, String, Int)] = + SigningBasketX.signingBasketProvider.vend.getSigningBasketMemberExecutions(basketId).map(m => (m.memberId, m.state, m.attempts)) + + /** + * A payment that is lodged normally and cannot be booked afterwards: its creditor account exists when + * the payment is created and its IBAN no longer resolves when the payment is executed. + */ + private def lodgePaymentThatCannotBeBooked(): String = { + ibanAccounts.size should be >= 3 + val creditor = ibanAccounts(1) + val payment = lodgePayment(creditorIban = Some(creditor.accountRouting.address)) + BankAccountRouting.findAll( + By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString), + By(BankAccountRouting.BankId, creditor.bankId.value), + By(BankAccountRouting.AccountId, creditor.accountId.value), + By(BankAccountRouting.AccountRoutingAddress, creditor.accountRouting.address)).foreach(_.delete_!) + payment + } + + private def storedBasketStatusRaw(basketId: String): String = + MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basketId)).map(_.Status.get).openOrThrowException("basket") + + feature("BG v1.3 signing baskets - answering the authorisation books the payments, and only then is the basket ACTC") { + scenario("S4: both payments are booked once, each is DONE, and the basket is ACTC", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { val ibanFrom = ibanAccounts.head val ibanTo = ibanAccounts.last - val started = startedBasket() - val fromBefore = balanceOf(ibanFrom) - val toBefore = balanceOf(ibanTo) + val started = startedBasket(paymentCount = 2) + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) - val deadline = System.currentTimeMillis() + 8000 - while (System.currentTimeMillis() < deadline && balanceOf(ibanTo) == toBefore) Thread.sleep(250) - withClue(s"payment status ${storedPaymentStatus(started.paymentIds.head)}, basket ${storedBasketStatus(started.basketId)}: ") { + withClue("booked before the response, not eventually: ") { + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + balanceOf(ibanTo) should equal(toBefore + 2 * 2001) + } + started.paymentIds.foreach(storedPaymentStatus(_) should equal("COMPLETED")) + memberResults(started.basketId).map(r => (r._2, r._3)) should equal(List(("DONE", 1), ("DONE", 1))) + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + } + + scenario("S4: a payment that cannot be booked leaves the basket RCVD, the earlier payment booked, and the members held", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val good = lodgePayment() + val bad = lodgePaymentThatCannotBeBooked() + val basketId = createBasket(List(good, bad)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + + val response = answerAuthorisation(basketId, authorisationId) + response.code should equal(200) + (response.body \ "scaStatus").extract[String] should equal("finalised") + (response.body \ "psuMessage").extract[String] should include("not every payment") + + withClue("only the first payment moved money: ") { balanceOf(ibanFrom) should equal(fromBefore - 2001) balanceOf(ibanTo) should equal(toBefore + 2001) } + storedPaymentStatus(good) should equal("COMPLETED") + storedPaymentStatus(bad) should equal(awaitingSca) + memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(good -> "DONE", bad -> "FAILED")) + withClue("reported as RCVD although stored as incomplete: ") { + storedBasketStatusRaw(basketId) should equal("EXECUTION_INCOMPLETE") + (makeGetRequest((basketUrl(basketId) / "status").GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + } + withClue("the payment still waiting stays held, so it cannot be put in another basket: ") { + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(bad))}}"""), 409, "REFERENCE_STATUS_INVALID", "the failed payment in a second basket") + } + withClue("the basket is no longer RCVD to be deleted: ") { + expectRefusal(makeDeleteRequest(basketUrl(basketId).DELETE <@ (user1)), 409, "STATUS_INVALID", "deleting an incompletely executed basket") + } + } + + scenario("S4: executing again books nothing twice, retries a failed payment a limited number of times, then leaves it", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val good = lodgePayment() + val bad = lodgePaymentThatCannotBeBooked() + val basketId = createBasket(List(good, bad)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId).code should equal(200) + val afterFirst = balanceOf(ibanFrom) + + (1 to 4).foreach { _ => + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + } + withClue("the booked payment was not booked again: ") { balanceOf(ibanFrom) should equal(afterFirst) } + memberResults(basketId).map(r => r._1 -> (r._2, r._3)).toMap should equal(Map(good -> ("DONE", 1), bad -> ("FAILED", 3))) + } + + scenario("S4: a member left UNKNOWN is reconciled by its transaction id, and is otherwise left alone", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val booked = lodgePayment() + val unbooked = lodgePayment() + val basketId = createBasket(List(booked, unbooked)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId).code should equal(200) + val afterBooking = balanceOf(ibanFrom) + storedBasketStatus(basketId) should equal(Some("ACTC")) + + // As if the executor stopped before it recorded either outcome: the basket is back to executing, + // and the first payment really was booked (it carries its transaction id), the second was not. + val provider = SigningBasketX.signingBasketProvider.vend + provider.transitionSigningBasketStatus(basketId, "ACTC", "AUTHORISING") + List(booked, unbooked).foreach(id => provider.transitionSigningBasketMemberExecution(basketId, "payment", id, Set("DONE"), "UNKNOWN", "test")) + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, unbooked)).openOrThrowException("payment") + .mStatus(awaitingSca).mTransactionIDs("").saveMe() + + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + withClue("nothing was booked again: ") { balanceOf(ibanFrom) should equal(afterBooking) } + memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(booked -> "DONE", unbooked -> "UNKNOWN")) + storedBasketStatusRaw(basketId) should equal("EXECUTION_INCOMPLETE") } } From 3dd6c2a6a16d0232f9dfcb6d4609988d36554b0e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:19:07 +0200 Subject: [PATCH 23/40] feat: let the TPP read what happened to each member of a signing basket The standard's statuses cannot say that a basket's first payment was booked and its second was not. Add GET /signing-baskets/{basketId}/execution, an extension of the ASPSP, that returns the basket's reported status and, for each member, its type, id, state, detail and the number of attempts. Only the TPP that created the basket may read it; anyone else, and an unknown basket, get the same 403 as every other basket operation. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 46 +++++++++++++++++++ .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 10 ++++ .../v1_3/SigningBasketServiceSBSApiTest.scala | 18 ++++++++ 3 files changed, 74 insertions(+) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index e4797d0835..6cce11ea16 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -368,6 +368,51 @@ Returns the status of a signing basket object. http4sPartialFunction = Some(getSigningBasketStatus) ) + // ── GET /signing-baskets/BASKETID/execution ─────────────────────────── + // Not part of the standard. The basket's status can say only RCVD or ACTC; when the authorisation was + // answered but a payment could not be booked, this says what happened to each member. + val getSigningBasketExecution: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `bgV13Prefix` / "signing-baskets" / basketid / "execution" => + EndpointHelpers.executeAndRespond(req) { cc => + val callContext = Some(cc) + for { + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) + members <- Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketMemberExecutions(basketid)) + } yield { + getSigningBasketExecutionResultsJson(basket, members) + } + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getSigningBasketExecution), + "GET", + "/signing-baskets/BASKETID/execution", + "Read what executing the signing basket did to each member", + s"""${mockedDataText(false)} +This is an extension of the ASPSP, not part of the Berlin Group standard. + +Answering the authorisation of a signing basket books its payments one after another. The basket is ACTC +only when every one was booked. When one could not be, the basket stays RCVD, and this call says what became +of each member: PENDING (not started), EXECUTING, DONE (booked), FAILED (refused, and may be tried again) or +UNKNOWN (the executor stopped without recording an outcome; an operator reconciles it). + +Only the TPP that created the basket may read this. +""", + EmptyBody, + JvalueCaseClass(json.parse("""{ + "transactionStatus" : "RCVD", + "members" : [ + { "memberType" : "payment", "memberId" : "4f4a8b7f-9968-4183-92ab-ca512b396bfc", "state" : "DONE", "detail" : "Booked", "attempts" : 1 }, + { "memberType" : "payment", "memberId" : "88695566-6642-46d5-9985-0d824624f507", "state" : "FAILED", "detail" : "Booking failed", "attempts" : 1 } + ] +}""")), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, UnknownError), + apiTagSigningBaskets :: Nil, + http4sPartialFunction = Some(getSigningBasketExecution) + ) + // ── POST /signing-baskets/BASKETID/authorisations ───────────────────── val startSigningBasketAuthorisation: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" => @@ -618,6 +663,7 @@ There are the following request types on this access path: .orElse(getSigningBasketAuthorisation(req)) .orElse(getSigningBasketScaStatus(req)) .orElse(getSigningBasketStatus(req)) + .orElse(getSigningBasketExecution(req)) .orElse(startSigningBasketAuthorisation(req)) .orElse(updateSigningBasketPsuData(req)) } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index 6e2f15f197..2b16bd42fc 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -77,6 +77,9 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ authorisationId: String, psuMessage: String, _links: SigningBasketScaLinksV13) + // An ASPSP extension: what executing the basket's authorisation did to each member. + case class SigningBasketMemberResultJson(memberType: String, memberId: String, state: String, detail: String, attempts: Int) + case class SigningBasketExecutionResultsJson(transactionStatus: String, members: List[SigningBasketMemberResultJson]) case class SigningBasketGetResponseJson( transactionStatus: String, payments: Option[List[String]], @@ -927,6 +930,13 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ ) } + def getSigningBasketExecutionResultsJson(basket: SigningBasketContent, + members: List[code.signingbaskets.SigningBasketMemberExecution]): SigningBasketExecutionResultsJson = + SigningBasketExecutionResultsJson( + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), + members = members.map(m => SigningBasketMemberResultJson(m.memberType, m.memberId, m.state, m.detail, m.attempts)) + ) + def getSigningBasketStatusResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 9c8dfc2a3f..4a85eb9e79 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -67,6 +67,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { object getSigningBasketScaStatus extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasketScaStatus)) object getSigningBasketAuthorisation extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasketAuthorisation)) object updateSigningBasketPsuData extends Tag(nameOf(APIMethods_SigningBasketsApi.updateSigningBasketPsuData)) + object getSigningBasketExecution extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasketExecution)) // ───────────────────────────── fixtures ───────────────────────────── @@ -905,6 +906,23 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } + scenario("S4: the creating TPP reads what happened to each member, and nobody else can", BerlinGroupV1_3, SBS, getSigningBasketExecution) { + enableBasketAuthorisation() + val good = lodgePayment() + val bad = lodgePaymentThatCannotBeBooked() + val basketId = createBasket(List(good, bad)) + val authorisationId = (startAuthorisation(basketId).body \\ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId).code should equal(200) + + val response = makeGetRequest((basketUrl(basketId) / "execution").GET <@ (user1)) + response.code should equal(200) + (response.body \\ "transactionStatus").extract[String] should equal("RCVD") + val members = (response.body \\ "members").children.map(m => (m \\ "memberId").extract[String] -> (m \\ "state").extract[String]) + members should equal(List(good -> "DONE", bad -> "FAILED")) + expectRefusal(makeGetRequest((basketUrl(basketId) / "execution").GET <@ (user2)), 403, "RESOURCE_UNKNOWN", "another TPP reading the results") + expectRefusal(makeGetRequest((basketUrl(UUID.randomUUID().toString) / "execution").GET <@ (user1)), 403, "RESOURCE_UNKNOWN", "an unknown basket") + } + scenario("S4: executing again books nothing twice, retries a failed payment a limited number of times, then leaves it", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { enableBasketAuthorisation() val ibanFrom = ibanAccounts.head From 82321384c8a53cf7f463e54a68670b52828a9392 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:22:21 +0200 Subject: [PATCH 24/40] feat: resume signing basket executions that stopped If the process booking a basket's payments dies after the basket was claimed, the basket stays AUTHORISING and its members stay held, and nothing picked it up. Add a scheduled task that marks members left EXECUTING past a lease as UNKNOWN and executes every basket that has sat AUTHORISING or EXECUTION_INCOMPLETE for the lease again from where it stopped. Members already DONE are skipped, a payment that carries a transaction id is never booked again, and a member left UNKNOWN is reconciled by its transaction id or left for an operator. Several nodes may run it at once: every member and every status change is claimed with a conditional update, and a test with three concurrent executors confirms each payment is booked once. The interval and the lease are configurable, and the task is on by default. --- .../resources/props/sample.props.template | 7 ++ .../main/scala/bootstrap/liftweb/Boot.scala | 1 + .../group/v1_3/SigningBasketExecution.scala | 19 ++++++ .../scheduler/SigningBasketScheduler.scala | 64 +++++++++++++++++++ .../v1_3/SigningBasketServiceSBSApiTest.scala | 47 ++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 obp-api/src/main/scala/code/scheduler/SigningBasketScheduler.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index ebfeb1dae7..1d792aa0a8 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1686,6 +1686,13 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER # A payment on any other connector whose booking failed is left UNKNOWN for an operator. #signing_basket_member_max_attempts = 3 +# Resuming signing basket executions that stopped (for instance because the process booking the payments died). +# The interval is in seconds (0 switches it off); the lease is how long a basket or member may sit without moving +# before it counts as stopped. A member left executing past the lease becomes UNKNOWN and is reconciled by its +# transaction id, or left for an operator. +#signing_basket_resume_interval_in_seconds = 593 +#signing_basket_execution_lease_in_seconds = 300 + # Support multiple brands on one instance. Note this needs checking on a clustered environment #brands_enabled=false diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 419ac5a231..6eaef2f8dc 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -643,6 +643,7 @@ class Boot extends MdcLoggable { } ConsentScheduler.startAll() TransactionScheduler.startAll() + SigningBasketScheduler.startAll() code.metrics.MetricsProps.enableMetricsScheduler match { diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index a94bceb371..a2570630c4 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -86,6 +86,25 @@ object SigningBasketExecution extends MdcLoggable { loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) } + /** + * Picks up executions that stopped: members left EXECUTING past the lease become UNKNOWN, and every basket + * still AUTHORISING or EXECUTION_INCOMPLETE that has not moved for the lease is executed again from where + * it stopped. Safe to run on several nodes at once, since each member and each status change is claimed + * with a conditional update. Returns how many baskets were looked at. + */ + def resumePending(leaseSeconds: Long, limit: Int): Future[Int] = { + provider.markStaleSigningBasketMembersUnknown(leaseSeconds) + val baskets = provider.getSigningBasketsAwaitingExecution(leaseSeconds, limit) + baskets.foldLeft(Future.successful(())) { (previous, basketId) => + previous.flatMap(_ => execute(basketId, None).transform { + case Failure(error) => + logger.error(s"Resuming the execution of signing basket $basketId failed", error) + Success(false) + case ok => ok + }.map(_ => ())) + }.map(_ => baskets.size) + } + private def finish(basketId: String, allDone: Boolean): Future[Boolean] = Future { val authorising = ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL val incomplete = ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL diff --git a/obp-api/src/main/scala/code/scheduler/SigningBasketScheduler.scala b/obp-api/src/main/scala/code/scheduler/SigningBasketScheduler.scala new file mode 100644 index 0000000000..571b632a9b --- /dev/null +++ b/obp-api/src/main/scala/code/scheduler/SigningBasketScheduler.scala @@ -0,0 +1,64 @@ +/** +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.scheduler + +import code.api.berlin.group.v1_3.SigningBasketExecution +import code.api.util.APIUtil +import code.util.Helper.MdcLoggable + +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} + +/** + * Resumes signing basket executions that stopped, for instance because the process that was booking + * the payments died. See SigningBasketExecution for what resuming does and does not do. + * + * The interval is in `signing_basket_resume_interval_in_seconds` (default 593; 0 switches it off) and the + * lease, the time a basket or member may sit without moving before it counts as stopped, in + * `signing_basket_execution_lease_in_seconds` (default 300). + */ +object SigningBasketScheduler extends MdcLoggable { + + def startAll(): Unit = { + val interval = APIUtil.getPropsAsIntValue("signing_basket_resume_interval_in_seconds", 593) + if (interval > 0) { + val lease = APIUtil.getPropsAsIntValue("signing_basket_execution_lease_in_seconds", 300) + SchedulerUtil.startTask(interval = interval, () => resume(lease), initialDelay = 30) + } else { + logger.warn("|---> Skipping resumeSigningBasketExecutions task: signing_basket_resume_interval_in_seconds set to 0") + } + } + + private def resume(leaseSeconds: Int): Unit = + Try(Await.result(SigningBasketExecution.resumePending(leaseSeconds, limit = 20), 5.minutes)) match { + case Success(0) => logger.debug("|---> No signing basket execution to resume") + case Success(n) => logger.info(s"|---> Looked at $n signing basket execution(s) that had stopped") + case Failure(error) => logger.error("Error in resumeSigningBasketExecutions!", error) + } +} diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 4a85eb9e79..e89332c1e2 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -966,6 +966,53 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } + feature("BG v1.3 signing baskets - an execution that stopped is resumed from where it stopped") { + scenario("S4: a basket claimed but never started is executed by the resumption, once, and only after the lease", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val payments = List(lodgePayment(), lodgePayment()) + val basketId = createBasket(payments) + val provider = SigningBasketX.signingBasketProvider.vend + // The state a crash leaves behind: the answer was accepted and the basket claimed, and nothing was booked. + provider.transitionSigningBasketStatus(basketId, "RCVD", "AUTHORISING") + provider.createSigningBasketMemberExecutions(basketId, payments.map("payment" -> _)) + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + + withClue("within the lease it is left alone: ") { + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(3600, 50), 60.seconds) + balanceOf(ibanFrom) should equal(fromBefore) + storedBasketStatusRaw(basketId) should equal("AUTHORISING") + } + Thread.sleep(1200) + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(1, 50), 60.seconds) + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + balanceOf(ibanTo) should equal(toBefore + 2 * 2001) + memberResults(basketId).map(r => (r._2, r._3)) should equal(List(("DONE", 1), ("DONE", 1))) + storedBasketStatus(basketId) should equal(Some("ACTC")) + + withClue("resuming again books nothing: ") { + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(1, 50), 60.seconds) + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + } + } + + scenario("S4: two resumptions at once book each payment once", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + import scala.concurrent.ExecutionContext.Implicits.global + val ibanFrom = ibanAccounts.head + val payments = List(lodgePayment(), lodgePayment()) + val basketId = createBasket(payments) + val provider = SigningBasketX.signingBasketProvider.vend + provider.transitionSigningBasketStatus(basketId, "RCVD", "AUTHORISING") + provider.createSigningBasketMemberExecutions(basketId, payments.map("payment" -> _)) + val fromBefore = balanceOf(ibanFrom) + Thread.sleep(1200) + val resumptions = (1 to 3).map(_ => Future(Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds))) + Await.result(Future.sequence(resumptions), 120.seconds) + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + memberResults(basketId).map(_._3) should equal(List(1, 1)) + } + } + // ───────────────────────── concurrency ───────────────────────── feature("BG v1.3 signing baskets - a delete racing the final answer has exactly one winner") { From 4750de43d9af0a66be43e98fd89d50515bfd468f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:26:08 +0200 Subject: [PATCH 25/40] refactor: share binding a consent to its PSU and activating a consent The consent authorisation binds the consent to the PSU, by copying the PSU's authentication context onto it and making the PSU its user, in two steps written inline in the route. A signing basket that activates a consent has to do the same. Move the two steps into Consent.bindBerlinGroupConsentToPsu, which the route now calls, and add BerlinGroupConsentActivation.activate: grant the access an allAccounts consent leaves open, mark the consent valid, bind it. It is idempotent, so a basket resumed after a stop can run it again. The consent authorisation route behaves as before (consent and AIS suites unchanged). --- .../v1_3/BerlinGroupConsentActivation.scala | 62 +++++++++++++++++++ .../berlin/group/v1_3/Http4sBGv13AIS.scala | 11 +--- .../scala/code/api/util/ConsentUtil.scala | 19 ++++++ 3 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala new file mode 100644 index 0000000000..53c90d647f --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala @@ -0,0 +1,62 @@ +/** +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.berlin.group.v1_3 + +import code.api.util.APIUtil.unboxFullOrFail +import code.api.util.ErrorMessages.{ConsentAccountAccessCannotBeGranted, ConsentUpdateStatusError} +import code.api.util.{CallContext, Consent} +import code.consent.{ConsentStatus, ConsentTrait, Consents} +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.User + +import scala.concurrent.Future + +/** + * Makes a Berlin Group consent valid once the PSU's SCA for it has succeeded: grants the access an + * "allAccounts" consent leaves open, marks the consent valid, and binds it to the PSU. + * + * The consent authorisation (PUT /consents/{id}/authorisations/{id}) performs the same steps inline, + * interleaved with checking the answer. A signing basket checks the answer once for all its members and + * then activates each consent here. The order is the one the consent route documents: grant before the + * status changes, so a consent never becomes valid without the access it names; bind last. + * + * Idempotent, so that a basket resumed after a stop can run it again: a consent already valid and bound + * to this PSU is returned as it is. + */ +object BerlinGroupConsentActivation { + + def activate(consent: ConsentTrait, psu: User, callContext: Option[CallContext]): Future[ConsentTrait] = + if (consent.status == ConsentStatus.valid.toString && consent.userId == psu.userId) Future.successful(consent) + else for { + _ <- Consent.grantBerlinGroupAvailableAccountsAccess(psu, consent) + .map(unboxFullOrFail(_, callContext, ConsentAccountAccessCannotBeGranted)) + valid <- Future(Consents.consentProvider.vend.updateConsentStatus(consent.consentId, ConsentStatus.valid)) + .map(unboxFullOrFail(_, callContext, ConsentUpdateStatusError)) + _ <- Consent.bindBerlinGroupConsentToPsu(consent.consentId, psu, callContext) + } yield valid +} 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 ce1b315f68..be25a08c14 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 @@ -704,16 +704,7 @@ object Http4sBGv13AIS extends MdcLoggable { _ <- NewStyle.function.tryons(ConsentUpdateStatusError, 400, callContext) { consent.toList.size == 1 } - _ <- Future { - val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(psu.userId) - .map(_.map(i => BasicUserAuthContext(i.key, i.value))) - ConsentAuthContextProvider.consentAuthContextProvider.vend.createOrUpdateConsentAuthContexts(consentId, authContexts.getOrElse(Nil)) - } map { - unboxFullOrFail(_, callContext, ConsentUserAuthContextCannotBeAdded) - } - _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, psu)) map { - unboxFullOrFail(_, callContext, ConsentUserCannotBeAdded) - } + _ <- Consent.bindBerlinGroupConsentToPsu(consentId, psu, callContext) } yield { createPutConsentResponseJson(consent.toList.head) } diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index c0da1c82e2..d20f3c5ae8 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -2377,6 +2377,25 @@ object Consent extends MdcLoggable { } } + /** + * Bind a Berlin Group consent to the PSU who authorised it: copy the PSU's authentication context onto the + * consent, and make the PSU the consent's user. Both the consent authorisation and a signing basket that + * activates a consent do this once the SCA has succeeded. + */ + def bindBerlinGroupConsentToPsu(consentId: String, psu: User, callContext: Option[CallContext]): Future[Unit] = + for { + _ <- Future { + val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(psu.userId) + .map(_.map(i => BasicUserAuthContext(i.key, i.value))) + ConsentAuthContextProvider.consentAuthContextProvider.vend.createOrUpdateConsentAuthContexts(consentId, authContexts.getOrElse(Nil)) + } map { + APIUtil.unboxFullOrFail(_, callContext, ConsentUserAuthContextCannotBeAdded) + } + _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, psu)) map { + APIUtil.unboxFullOrFail(_, callContext, ConsentUserCannotBeAdded) + } + } yield () + def createUKConsentJWT( user: Option[User], bankId: Option[String], From c4abc31c0a7a0954b3e4ca50712e12afe7d42206 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:32:58 +0200 Subject: [PATCH 26/40] feat: activate a signing basket's consents when its authorisation is answered A basket holding a consent could not be authorised: the PUT refused it, because nothing made the consent valid. Execute consent members as well. Before the answer is checked, the consents must exist and still be waiting for authorisation, and the PSU must hold the accounts each names, as when the PSU authorises a consent on its own; none of that changes anything. After the answer, each consent is activated in order with the payments: its access is granted, it becomes valid, and it is bound to the PSU the basket was authorised by. Activation is idempotent, so a consent may be claimed again, up to the attempts allowed, and a consent already valid and bound to the PSU is simply DONE. A PSU who does not hold a consent's accounts is refused with 403 CONSENT_UNKNOWN, the standard's code for a consent that cannot be found with respect to the PSU. The same refusal on the consent authorisation route used to carry the HTTP status in place of a code. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 22 ++++-- .../group/v1_3/SigningBasketExecution.scala | 52 ++++++++++++- .../code/api/util/BerlinGroupError.scala | 4 +- .../scala/code/api/util/ErrorMessages.scala | 1 - .../v1_3/SigningBasketServiceSBSApiTest.scala | 77 +++++++++++++------ 5 files changed, 120 insertions(+), 36 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 6cce11ea16..2d76fa32ae 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -36,11 +36,12 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, ge import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats -import code.api.util.{ApiTag, CallContext, NewStyle} +import code.api.util.{ApiTag, CallContext, Consent, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.SigningBasketNewStyle import code.api.util.newstyle.SigningBasketNewStyle.{AuthorisationOperation, CreatorOnly} import code.bankconnectors.Connector +import code.consent.{ConsentStatus, Consents} import code.signingbaskets.{SigningBasketMemberState, SigningBasketX} import code.util.Helper.{MdcLoggable, booleanToFuture} import com.github.dwickern.macros.NameOf.nameOf @@ -541,9 +542,6 @@ This applies in the following scenarios: _ <- booleanToFuture(SigningBasketAuthorisationDisabled, failCode = 403, cc = callContext) { getPropsAsBoolValue("signing_basket_authorisation_enabled", defaultValue = false) } - _ <- booleanToFuture(SigningBasketConsentNotSupported, failCode = 400, cc = callContext) { - basket.consents.forall(_.isEmpty) - } _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { basket.basket.status == ConstantsBG.SigningBasketsStatus.RCVD.toString } @@ -557,9 +555,20 @@ This applies in the following scenarios: _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { !members.exists(_._2.exists(_._1.status == COMPLETED.toString)) } + consentIds = basket.consents.getOrElse(Nil) + consents <- Future(consentIds.map(id => id -> Consents.consentProvider.vend.getConsentByConsentId(id))) + _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext)(consents.forall(_._2.isDefined)) + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + consents.forall(_._2.exists(_.status == ConsentStatus.received.toString)) + } // The answer is the PSU's, relayed by the TPP under Embedded, so it is checked against the // challenge's own PSU rather than the principal on the token. (psu, _) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) + // The PSU has to hold the accounts each consent names, as when they authorise a consent on its own. + // Before the answer is checked and before anything changes: activation is not one transaction. + _ <- consents.flatMap(_._2.toList).foldLeft(Future.successful(())) { (previous, consent) => + previous.flatMap(_ => Consent.assertBerlinGroupConsentAccountsHeld(psu, consent, callContext).map(_ => ())) + } (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, @@ -585,7 +594,8 @@ This applies in the following scenarios: basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(claimed.openOr(false)) // Each member is recorded, then booked in order. The basket becomes ACTC only if every one is. - _ <- Future(provider.createSigningBasketMemberExecutions(basketId, paymentIds.map(SigningBasketMemberState.PaymentType -> _))) + _ <- Future(provider.createSigningBasketMemberExecutions(basketId, + paymentIds.map(SigningBasketMemberState.PaymentType -> _) ::: consentIds.map(SigningBasketMemberState.ConsentType -> _))) allDone <- SigningBasketExecution.execute(basketId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge, executionIncomplete = !allDone) @@ -651,7 +661,7 @@ There are the following request types on this access path: } } }""")), - List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketNotFound, SigningBasketAuthorisationNotFound, SigningBasketAuthorisationVariantNotSupported, SigningBasketAuthorisationDisabled, SigningBasketConsentNotSupported, SigningBasketStatusInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, InvalidChallengeAnswer, UnknownError), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketNotFound, SigningBasketAuthorisationNotFound, SigningBasketAuthorisationVariantNotSupported, SigningBasketAuthorisationDisabled, SigningBasketStatusInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, InvalidChallengeAnswer, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(updateSigningBasketPsuData) ) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index a2570630c4..af2b40c3a4 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -29,7 +29,8 @@ package code.api.berlin.group.v1_3 import code.api.berlin.group.ConstantsBG import code.api.util.APIUtil.getPropsAsIntValue -import code.api.util.{CallContext, NewStyle} +import code.api.util.{CallContext, Consent, NewStyle} +import code.consent.{ConsentStatus, Consents} import code.signingbaskets.{SigningBasketMemberExecution, SigningBasketMemberState, SigningBasketX} import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -86,6 +87,10 @@ object SigningBasketExecution extends MdcLoggable { loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) } + /** The PSU the basket was authorised by, bound when its authorisation was started. */ + private def basketPsu(basketId: String): Option[String] = + provider.getSigningBasketByBasketId(basketId).toOption.flatMap(_.basket.psuUserId) + /** * Picks up executions that stopped: members left EXECUTING past the lease become UNKNOWN, and every basket * still AUTHORISING or EXECUTION_INCOMPLETE that has not moved for the lease is executed again from where @@ -124,9 +129,50 @@ object SigningBasketExecution extends MdcLoggable { private def executeMember(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = member.memberType match { case PaymentType => executePayment(basketId, member, callContext) - // Activating a consent is not available yet, so a basket holding one is not executed. - case _ => record(basketId, member, Set(Pending, Failed), Failed, "Activating a consent through a signing basket is not available").map(_ => false) + case ConsentType => executeConsent(basketId, member, callContext) + case other => record(basketId, member, Set(Pending, Failed), Failed, s"Unknown member type $other").map(_ => false) + } + + /** + * Activates a consent: it becomes valid and is bound to the PSU, as if the PSU had authorised it on its + * own. Activation is idempotent (a consent already valid and bound to this PSU is simply DONE), so unlike a + * payment a consent may be claimed again from any state short of DONE, up to the attempts allowed. + */ + private def executeConsent(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = { + def finishWith(to: String, detail: String): Future[Boolean] = + record(basketId, member, Set(Executing, Unknown), to, detail).map(_ => to == Done) + val claimFrom = if (member.attempts == 0) Set(Pending) else if (member.attempts < maxAttempts) Set(Pending, Failed, Unknown) else Set(Pending) + record(basketId, member, claimFrom, Executing, "").flatMap { + case false => Future.successful(false) + case true => + basketPsu(basketId) match { + case None => finishWith(Failed, "The basket has no PSU, so there is nobody to bind the consent to") + case Some(psuUserId) => + val activation = for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(member.memberId)).map { + case Full(found) => found + case _ => throw new IllegalStateException("The consent cannot be read") + } + (psu, _) <- NewStyle.function.findByUserId(psuUserId, callContext) + outcome <- + if (consent.status == ConsentStatus.valid.toString && consent.userId == psuUserId) + Future.successful("Already valid") + else if (consent.status != ConsentStatus.received.toString) + Future.failed(new IllegalStateException(s"The consent is ${consent.status}, not waiting for authorisation")) + else for { + // The binding point, so the holdings check is repeated here: an account can change hands + // between the answer and the activation. + _ <- Consent.assertBerlinGroupConsentAccountsHeld(psu, consent, callContext) + _ <- BerlinGroupConsentActivation.activate(consent, psu, callContext) + } yield "Activated" + } yield outcome + activation.transform(Success(_)).flatMap { + case Success(detail) => finishWith(Done, detail) + case Failure(error) => finishWith(Failed, Option(error.getMessage).getOrElse(error.getClass.getSimpleName)) + } + } } + } private def record(basketId: String, member: SigningBasketMemberExecution, from: Set[String], to: String, detail: String): Future[Boolean] = Future { provider.transitionSigningBasketMemberExecution(basketId, member.memberType, member.memberId, from, to, detail).openOr(false) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index 76875926ba..259b8e30aa 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -122,7 +122,9 @@ object BerlinGroupError { case "404" if message.contains("OBP-35052") => "RESOURCE_UNKNOWN" case "409" if message.contains("OBP-35053") => "STATUS_INVALID" case "403" if message.contains("OBP-35054") => "SERVICE_BLOCKED" - case "400" if message.contains("OBP-35055") => "SERVICE_INVALID" + // The PSU does not hold the accounts a consent names. The consent cannot be authorised by them, which is + // the standard's "consent cannot be found with respect to the PSU". + case "403" if message.contains("OBP-35037") => "CONSENT_UNKNOWN" case "400" if message.contains("OBP-35056") => "RESOURCE_UNKNOWN" case "409" if message.contains("OBP-35057") => "REFERENCE_STATUS_INVALID" case "400" if message.contains("OBP-35058") => "REFERENCE_MIX_INVALID" 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 9b5bc89d70..793e4be700 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -883,7 +883,6 @@ object ErrorMessages { val SigningBasketAuthorisationNotFound = "OBP-35052: Signing basket authorisation not found by AUTHORISATION_ID. " val SigningBasketStatusInvalid = "OBP-35053: The signing basket's status does not allow this operation. " val SigningBasketAuthorisationDisabled = "OBP-35054: Authorising signing baskets is not enabled at this instance. " - val SigningBasketConsentNotSupported = "OBP-35055: This signing basket contains a consent, and authorising a consent through a signing basket is not supported yet. " val SigningBasketMemberNotFound = "OBP-35056: A payment or consent named for the signing basket was not found. " val SigningBasketMemberStatusInvalid = "OBP-35057: A payment or consent named for the signing basket is not in a state that can be authorised, or is already in another signing basket. " val SigningBasketMemberMixInvalid = "OBP-35058: The payments and consents named for the signing basket cannot be authorised together. " diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index e89332c1e2..2a11020864 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -562,36 +562,63 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { storedBasketStatus(started.basketId) should equal(Some("ACTC")) } - scenario("C10: a basket with a consent member is refused at authorisation before anything changes (consent activation is not supported yet)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + scenario("C10: authorising a basket of a consent makes it valid and binds it to the PSU", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { enableBasketAuthorisation() - val consentResponse = makePostRequest((V1_3_BG / "consents").POST <@ (user1), write(bgConsentPostBody())) - consentResponse.code should equal(201) - val consentId = (consentResponse.body \ "consentId").extract[String] - val payment = lodgePayment() + // One consent: a second recurring consent for the same PSU and TPP would end the first. + val consentIds = List.fill(1)((makePostRequest((V1_3_BG / "consents").POST <@ (user1), write(bgConsentPostBody())).body \\ "consentId").extract[String]) + val created = postBasket(s"""{"consentIds":${idList(consentIds)}}""") + created.code should equal(201) + val basketId = created.body.extract[SigningBasketResponseJson].basketId + val authorisationId = (startAuthorisation(basketId).body \\ "authorisationId").extract[String] - val consentOnly = postBasket(s"""{"consentIds":${idList(List(consentId))}}""") - consentOnly.code should equal(201) - val consentOnlyBasket = consentOnly.body.extract[SigningBasketResponseJson].basketId - val consentOnlyAuth = (startAuthorisation(consentOnlyBasket).body \ "authorisationId").extract[String] - expectRefusal(answerAuthorisation(consentOnlyBasket, consentOnlyAuth), 400, "SERVICE_INVALID", "authorising a consent-only basket") - storedBasketStatus(consentOnlyBasket) should equal(Some("RCVD")) - - // The consent is held by the first basket, so a mixed basket needs another one. - val secondConsent = makePostRequest((V1_3_BG / "consents").POST <@ (user1), write(bgConsentPostBody())) - val secondConsentId = (secondConsent.body \ "consentId").extract[String] - val mixed = postBasket(s"""{"paymentIds":${idList(List(payment))},"consentIds":${idList(List(secondConsentId))}}""") - mixed.code should equal(201) - val mixedBasket = mixed.body.extract[SigningBasketResponseJson].basketId - val mixedAuth = (startAuthorisation(mixedBasket).body \ "authorisationId").extract[String] - expectRefusal(answerAuthorisation(mixedBasket, mixedAuth), 400, "SERVICE_INVALID", "authorising a mixed basket") - withClue("the payment was not marked completed and the basket was not marked ACTC: ") { - storedBasketStatus(mixedBasket) should equal(Some("RCVD")) - storedPaymentStatus(payment) should equal(awaitingSca) + answerAuthorisation(basketId, authorisationId).code should equal(200) + consentIds.foreach { id => + val consent = code.consent.Consents.consentProvider.vend.getConsentByConsentId(id).openOrThrowException("consent") + withClue(s"consent $id: ") { + consent.status should equal(code.consent.ConsentStatus.valid.toString) + consent.userId should equal(resourceUser1.userId) + } } + memberResults(basketId).map(r => (r._2, r._3)) should equal(List(("DONE", 1))) + storedBasketStatus(basketId) should equal(Some("ACTC")) } - // Pending until consent activation is specified; the target is recorded so it is not forgotten. - ignore("C10 (target, execution phase): authorising a consent-only basket leaves the consent valid", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) {} + scenario("C10: a basket of a payment and a consent books the payment and activates the consent", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val payment = lodgePayment() + val consentId = (makePostRequest((V1_3_BG / "consents").POST <@ (user1), write(bgConsentPostBody())).body \\ "consentId").extract[String] + val created = postBasket(s"""{"paymentIds":${idList(List(payment))},"consentIds":${idList(List(consentId))}}""") + created.code should equal(201) + val basketId = created.body.extract[SigningBasketResponseJson].basketId + val authorisationId = (startAuthorisation(basketId).body \\ "authorisationId").extract[String] + val fromBefore = balanceOf(ibanFrom) + + answerAuthorisation(basketId, authorisationId).code should equal(200) + balanceOf(ibanFrom) should equal(fromBefore - 2001) + storedPaymentStatus(payment) should equal("COMPLETED") + code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId).map(_.status) should equal(net.liftweb.common.Full(code.consent.ConsentStatus.valid.toString)) + memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(payment -> "DONE", consentId -> "DONE")) + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + + scenario("C10: a PSU who does not hold the consent's accounts cannot authorise it through a basket, and nothing changes", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val consentId = createUnclaimedBerlinGroupConsent().consentId // names an account resourceUser2 does not hold + val created = postBasket(s"""{"consentIds":${idList(List(consentId))}}""", as = clientCredentialsSession) + created.code should equal(201) + val basketId = created.body.extract[SigningBasketResponseJson].basketId + val started = makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", resourceUser2.name))) + started.code should equal(201) + val authorisationId = (started.body \\ "authorisationId").extract[String] + + expectRefusal(answerAuthorisation(basketId, authorisationId, as = clientCredentialsSession), 403, "CONSENT_UNKNOWN", "a PSU without the accounts") + storedBasketStatus(basketId) should equal(Some("RCVD")) + code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId).map(_.status) should equal(net.liftweb.common.Full(code.consent.ConsentStatus.received.toString)) + withClue("the answer was not consumed: ") { + Challenges.ChallengeProvider.vend.getChallenge(authorisationId).map(_.successful) should equal(net.liftweb.common.Full(false)) + } + } } // ───────────────────────── unknown resources: C4, C8, C11 ───────────────────────── From 7cf8860cb250cd77d2f41a088c8363e45e89f0dd Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 11:34:51 +0200 Subject: [PATCH 27/40] docs: how to operate signing baskets Describe what the stored statuses and the per-member states mean, the properties that govern execution and its resumption, how to find baskets that did not complete, how to reconcile a member left UNKNOWN, and what to do with baskets created before ownership was recorded (quarantined, kept for audit, assigned explicitly if one has to be revived). --- docs/signing_basket_operations.md | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/signing_basket_operations.md diff --git a/docs/signing_basket_operations.md b/docs/signing_basket_operations.md new file mode 100644 index 0000000000..467b9192fc --- /dev/null +++ b/docs/signing_basket_operations.md @@ -0,0 +1,111 @@ +# Signing baskets: operating notes + +For whoever runs an OBP-API instance that offers the Berlin Group signing basket. The behaviour of the API +itself is in the ResourceDocs; this covers what an operator does and sees. + +## Turning authorisation on + +`signing_basket_authorisation_enabled` is `false` by default. While it is, creating, reading, deleting a +basket and starting an authorisation work, and answering the authorisation (the PUT) answers 403 +`SERVICE_BLOCKED`. Roll it out in stages: first the ownership guard (this change with the property off), +then the property, with the recovery rehearsal below in between. + +Related properties: + +| Property | Default | Meaning | +|---|---|---| +| `signing_basket_member_max_attempts` | 3 | Times a payment that failed to book is claimed again. Mapped connector only. | +| `signing_basket_resume_interval_in_seconds` | 593 | How often stopped executions are resumed. 0 switches it off. | +| `signing_basket_execution_lease_in_seconds` | 300 | How long a basket or member may sit without moving before it counts as stopped. | + +## What the stored status means + +A basket reports `RCVD`, `PATC`, `ACTC`, `CANC` or `RJCT`. Two more are stored and reported as `RCVD`: + +* `AUTHORISING`: the authorisation was answered correctly and the basket was claimed; its members are being + executed. +* `EXECUTION_INCOMPLETE`: execution stopped with a member that is not `DONE`. + +`ACTC` means every member is `DONE`. Each member has its own state in `SigningBasketMemberExecution`, and the +creating TPP reads it from `GET /signing-baskets/{basketId}/execution`. + +| Member state | Meaning | +|---|---| +| `PENDING` | Not started. | +| `EXECUTING` | Claimed by an executor. Past the lease it becomes `UNKNOWN`. | +| `DONE` | Payment booked, or consent activated. | +| `FAILED` | Refused before it took effect. Claimed again automatically, on the mapped connector, up to the attempts allowed. | +| `UNKNOWN` | The executor stopped without recording an outcome, or a connector other than the mapped one failed after it may have booked. | + +Several payments in one basket are not one transaction. A failure leaves the earlier payments booked. + +## Looking at baskets that did not complete + +```sql +-- Baskets that did not reach ACTC after their authorisation was answered +SELECT basketid, status, consumerid, psuuserid, updatedat +FROM signingbasket +WHERE status IN ('AUTHORISING', 'EXECUTION_INCOMPLETE') +ORDER BY updatedat; + +-- What happened to each member of one basket +SELECT membertype, memberid, position, state, detail, attempts, updatedat +FROM SigningBasketMemberExecution +WHERE basketid = '' +ORDER BY position; +``` + +## Members left UNKNOWN + +The resumption reconciles an `UNKNOWN` payment by its transaction id: if the payment carries one it was +booked, and the member becomes `DONE`. Without one, nothing proves whether it was booked, so it is left for you. + +1. Find the payment's debit in the ledger (the debtor account, the amount, the time of the execution). +2. If it was booked, set the payment's transaction id and mark the member `DONE`; the next resumption completes + the basket. If it was not, mark the member `FAILED`; it is then claimed again, up to the attempts allowed. + +On a connector other than the mapped one, a failure is always recorded as `UNKNOWN`, because that connector +may have booked before it failed, and automatic retry is off. + +## Baskets created before ownership was recorded + +Baskets created before this change have no creating TPP and no PSU. They cannot be attributed safely, so every +operation answers them as unknown (403 `RESOURCE_UNKNOWN`), they are never authorised, and their rows are kept. +Nothing assigns one to whoever asks first, and no property re-opens them. + +To find them: + +```sql +SELECT basketid, status, createdat +FROM signingbasket +WHERE consumerid IS NULL OR consumerid = ''; +``` + +If a particular one has to be revived, assign it explicitly, after establishing who created it: + +```sql +UPDATE signingbasket +SET consumerid = '' +WHERE basketid = '' AND (consumerid IS NULL OR consumerid = ''); +``` + +Its payments and consents are not held by any claim. If the basket is to be used, add the claims: + +```sql +INSERT INTO SigningBasketMemberClaim (memberkey, basketid, createdat, updatedat) +VALUES ('payment:', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +``` + +(one row per payment, `consent:` for consents). Without them another basket could take the same +payment. A basket that is only to be closed needs none of this; set its status to `CANC`. + +## Things that can still surprise + +* A payment waiting in a basket is still a payment waiting for SCA: if + `berlin_group_outdated_transactions_interval_in_seconds` is set, the outdated-payment task rejects it after + `berlin_group_outdated_transactions_time_in_seconds`, and the basket's member then fails as not waiting for + authorisation. +* A consent in a basket is `received` until activated, and the consent scheduler rejects a Berlin Group consent + that stays `received` for `berlin_group_outdated_consents_time_in_seconds`. +* The recurrence of a periodic payment is not stored, so a periodic payment cannot be recognised and refused when + it is put in a basket; it is treated as a single payment. From 30f84f971132ed87c96cb203d260922ee6d14c03 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 12:15:18 +0200 Subject: [PATCH 28/40] fix: make the deleteUser ResourceDoc description valid XML The description said the username is replaced with DELETED-. Rendered as markdown that is an unclosed HTML element, so the description does not parse as XML, which API Explorer and ResourceDocsTest both require (they parse the first few descriptions of the list). The list is unordered, so whether the test hits this one depends on which ResourceDocs exist: adding a ResourceDoc anywhere can move it into the first three and fail ResourceDocsTest. Put the text in a code span. Every one of the 805 v6.0.0 descriptions now parses. --- obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e72f62a5c8..c241553801 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 @@ -7748,7 +7748,7 @@ object Http4s400 { | |This is a soft delete: the database row is kept, but the User's personal data is scrambled i.e. overwritten with random values: | - |* The username is replaced with DELETED- + |* The username is replaced with `DELETED-` |* The first name, last name and email are replaced with random values |* The password is replaced with a random value and the user is invalidated, so the User can no longer log in |* Any User Invitation that created the User is scrambled in the same way From 3c2b0df75ead7a0414ff4f834ccd6b6c5d24559c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 12:22:36 +0200 Subject: [PATCH 29/40] chore: allowlist the reviewed deleteUser description difference The parity audit compares each ResourceDoc description with the Lift baseline. The deleteUser description now puts DELETED- in a code span so that it parses as XML; record that as a reviewed difference, using the helper, rather than editing the baseline. --- scripts/resource_doc_baseline/parity_allowlist.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 49573fcf40..f16e3e01fb 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -131,6 +131,14 @@ } ], "field_mismatches": [ + { + "version": "v4_0_0", + "endpoint": "deleteUser", + "field": "description", + "reason": "Reviewed: the Lift description said DELETED-, which renders as an unclosed HTML element and does not parse as XML (API Explorer and ResourceDocsTest require that it does). The http4s description puts it in a code span; nothing else differs.", + "lift_digest": "f2e1d5e50e5b129805fcae2dbfe8e6fc681605ba65c67c1b734bf3f3a14a66d4", + "http4s_digest": "7883d1964bf7eb19e158179acdb87bd1e75136681f67ad2e6c35b966b6c8cde4" + }, { "version": "v4_0_0", "endpoint": "deleteExplicitCounterparty", From c3d8b52dd4b3027091a267bd83093cdb65f2b0f6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 12:31:43 +0200 Subject: [PATCH 30/40] chore: merge the deleteUser description allowlist entries There was already a reviewed entry for the deleteUser description, with the digest of the text before the code span. It now matched nothing, which the audit rejects as stale. Keep one entry, with the current digest and both reasons. --- scripts/resource_doc_baseline/parity_allowlist.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index f16e3e01fb..0079c1ffef 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -135,7 +135,7 @@ "version": "v4_0_0", "endpoint": "deleteUser", "field": "description", - "reason": "Reviewed: the Lift description said DELETED-, which renders as an unclosed HTML element and does not parse as XML (API Explorer and ResourceDocsTest require that it does). The http4s description puts it in a code span; nothing else differs.", + "reason": "Expanded description accurately documents verified soft-delete/PII-scramble behavior (AuthUser.scrambleAuthUser, scrambleDataOfResourceUser in the real handler). It also puts DELETED- in a code span: unquoted, that renders as an unclosed HTML element and does not parse as XML, which API Explorer and ResourceDocsTest require.", "lift_digest": "f2e1d5e50e5b129805fcae2dbfe8e6fc681605ba65c67c1b734bf3f3a14a66d4", "http4s_digest": "7883d1964bf7eb19e158179acdb87bd1e75136681f67ad2e6c35b966b6c8cde4" }, @@ -779,14 +779,6 @@ "lift_digest": "921d75d9e3f3f8829417e01b547c83bb66305f39e6125d07641d70228ba12762", "http4s_digest": "f17aeac76f848755b702722badaf8024590ced0c796cdf6467cc8f3ca0cabf2e" }, - { - "version": "v4_0_0", - "endpoint": "deleteUser", - "field": "description", - "reason": "Expanded description accurately documents verified soft-delete/PII-scramble behavior (AuthUser.scrambleAuthUser, scrambleDataOfResourceUser in the real handler).", - "lift_digest": "f2e1d5e50e5b129805fcae2dbfe8e6fc681605ba65c67c1b734bf3f3a14a66d4", - "http4s_digest": "017005c0edb02ebf0b68a20c3e82936cdb06b2febe71dbd87c49799ff6396d8d" - }, { "version": "v4_0_0", "endpoint": "deleteUser", From abdce3917c73e18b35a22b23c68b2f648e94d9da Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 14:36:03 +0200 Subject: [PATCH 31/40] fix: let a stopped consent activation be completed by resuming Activating a consent set it valid and then bound it to the PSU. A stop between the two left a consent that was valid and bound to nobody, which the resumption read as no longer waiting for authorisation and refused on every attempt, so it could never be repaired. Bind before the status changes: the status is now the last step, so a stop leaves the consent received, with its access granted and perhaps bound, and running the activation again completes it. A consent left valid and unbound by the earlier order is completed by binding it. A consent valid for another PSU is still refused. --- .../v1_3/BerlinGroupConsentActivation.scala | 31 ++++++++---- .../group/v1_3/SigningBasketExecution.scala | 2 +- .../v1_3/SigningBasketServiceSBSApiTest.scala | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala index 53c90d647f..16a73fd8d3 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala @@ -38,25 +38,40 @@ import scala.concurrent.Future /** * Makes a Berlin Group consent valid once the PSU's SCA for it has succeeded: grants the access an - * "allAccounts" consent leaves open, marks the consent valid, and binds it to the PSU. + * "allAccounts" consent leaves open, binds the consent to the PSU, and marks it valid. * * The consent authorisation (PUT /consents/{id}/authorisations/{id}) performs the same steps inline, * interleaved with checking the answer. A signing basket checks the answer once for all its members and - * then activates each consent here. The order is the one the consent route documents: grant before the - * status changes, so a consent never becomes valid without the access it names; bind last. + * then activates each consent here. * - * Idempotent, so that a basket resumed after a stop can run it again: a consent already valid and bound - * to this PSU is returned as it is. + * It can be run again after a stop at any point. The status changes last, so a stop leaves the consent + * `received`, with its access granted and perhaps already bound to the PSU, and running it again completes + * it. A consent made valid by an earlier version of this method, which set the status before binding, and + * left unbound by a stop between the two, is completed by binding it. A consent already valid and bound to + * this PSU is returned as it is. */ object BerlinGroupConsentActivation { + private def bound(consent: ConsentTrait): Option[String] = Consent.present(consent.userId) + + /** Whether activating this consent for this PSU can still lead to a valid consent bound to them. */ + def canActivate(consent: ConsentTrait, psuUserId: String): Boolean = + consent.status == ConsentStatus.received.toString || + (consent.status == ConsentStatus.valid.toString && bound(consent).forall(_ == psuUserId)) + def activate(consent: ConsentTrait, psu: User, callContext: Option[CallContext]): Future[ConsentTrait] = - if (consent.status == ConsentStatus.valid.toString && consent.userId == psu.userId) Future.successful(consent) - else for { + if (consent.status == ConsentStatus.valid.toString) { + bound(consent) match { + case Some(user) if user == psu.userId => Future.successful(consent) + // Valid but not bound: a stop between the two steps. Bind it; the access was granted before. + case None => Consent.bindBerlinGroupConsentToPsu(consent.consentId, psu, callContext).map(_ => consent) + case Some(_) => Future.failed(new IllegalStateException(s"The consent is already valid for another PSU")) + } + } else for { _ <- Consent.grantBerlinGroupAvailableAccountsAccess(psu, consent) .map(unboxFullOrFail(_, callContext, ConsentAccountAccessCannotBeGranted)) + _ <- Consent.bindBerlinGroupConsentToPsu(consent.consentId, psu, callContext) valid <- Future(Consents.consentProvider.vend.updateConsentStatus(consent.consentId, ConsentStatus.valid)) .map(unboxFullOrFail(_, callContext, ConsentUpdateStatusError)) - _ <- Consent.bindBerlinGroupConsentToPsu(consent.consentId, psu, callContext) } yield valid } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index af2b40c3a4..60c29f1614 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -157,7 +157,7 @@ object SigningBasketExecution extends MdcLoggable { outcome <- if (consent.status == ConsentStatus.valid.toString && consent.userId == psuUserId) Future.successful("Already valid") - else if (consent.status != ConsentStatus.received.toString) + else if (!BerlinGroupConsentActivation.canActivate(consent, psuUserId)) Future.failed(new IllegalStateException(s"The consent is ${consent.status}, not waiting for authorisation")) else for { // The binding point, so the holdings check is repeated here: an account can change hands diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 2a11020864..94368326aa 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -1040,6 +1040,53 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } + feature("BG v1.3 signing baskets - a consent activation that stopped part way is completed by resuming") { + // A basket claimed for execution with a consent that the stop left in the given state. + def stoppedConsentBasket(stopped: String => Unit): (String, String) = { + val consentId = createUnclaimedBerlinGroupConsent().consentId + stopped(consentId) + val provider = SigningBasketX.signingBasketProvider.vend + val basketId = provider.createSigningBasket(None, Some(List(consentId)), testConsumer.consumerId.get, Some(resourceUser1.userId)) + .openOrThrowException("basket").basketId + provider.transitionSigningBasketStatus(basketId, "RCVD", "AUTHORISING") + provider.createSigningBasketMemberExecutions(basketId, List("consent" -> consentId)) + (basketId, consentId) + } + def consentOf(id: String) = code.consent.Consents.consentProvider.vend.getConsentByConsentId(id).openOrThrowException("consent") + + scenario("C10: a consent made valid but not yet bound is bound, not refused as no longer waiting", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val (basketId, consentId) = stoppedConsentBasket { id => + code.consent.Consents.consentProvider.vend.updateConsentStatus(id, code.consent.ConsentStatus.valid) + } + consentOf(consentId).userId should not equal resourceUser1.userId + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(true) + consentOf(consentId).userId should equal(resourceUser1.userId) + consentOf(consentId).status should equal(code.consent.ConsentStatus.valid.toString) + memberResults(basketId).map(r => (r._2, r._3)) should equal(List(("DONE", 1))) + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + + scenario("C10: a consent bound but not yet valid is made valid", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val (basketId, consentId) = stoppedConsentBasket { id => + code.consent.Consents.consentProvider.vend.updateConsentUser(id, resourceUser1) + } + consentOf(consentId).status should equal(code.consent.ConsentStatus.received.toString) + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(true) + consentOf(consentId).status should equal(code.consent.ConsentStatus.valid.toString) + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + + scenario("C10: a consent already valid for another PSU is not taken over", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val (basketId, consentId) = stoppedConsentBasket { id => + code.consent.Consents.consentProvider.vend.updateConsentUser(id, resourceUser2) + code.consent.Consents.consentProvider.vend.updateConsentStatus(id, code.consent.ConsentStatus.valid) + } + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + consentOf(consentId).userId should equal(resourceUser2.userId) + memberResults(basketId).map(_._2) should equal(List("FAILED")) + } + } + // ───────────────────────── concurrency ───────────────────────── feature("BG v1.3 signing baskets - a delete racing the final answer has exactly one winner") { From 643626dcc4ca0b5695055818cdffdbb5df9b0fd7 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 14:40:14 +0200 Subject: [PATCH 32/40] fix: take a member's PSU from both identities it records, and check it again A payment records the principal that lodged it and, when it was lodged for somebody, the one it was lodged for, and the rule that lets a TPP address a payment accepts either. The basket read the PSU only from the second, so a payment a PSU lodged themselves, with nothing recorded as lodged for, joined a basket that named nobody. When an authorisation was started a different PSU could then be bound to it, and that PSU authorised a payment that was not theirs. Read the PSU from whichever of the two is a person (the TPP's own pseudo-user is not), judged against the consumer that created the basket rather than the caller, since the SCA front end is not the TPP. Check it again where the PSU is fixed: when the authorisation is started, and when the answer is checked, because members can change between the two. A PSU that every member naming one is not for is refused like any other attempt to address the basket. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 2 + .../util/newstyle/SigningBasketNewStyle.scala | 51 ++++++++++++++++--- .../v1_3/SigningBasketServiceSBSApiTest.scala | 44 ++++++++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 2d76fa32ae..9c5bbdc255 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -564,6 +564,8 @@ This applies in the following scenarios: // The answer is the PSU's, relayed by the TPP under Embedded, so it is checked against the // challenge's own PSU rather than the principal on the token. (psu, _) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) + // Every member that names a PSU must name this one, as when the authorisation was started. + _ <- SigningBasketNewStyle.requireMembersForPsu(basket, psu.userId, callContext) // The PSU has to hold the accounts each consent names, as when they authorise a consent on its own. // Before the answer is checked and before anything changes: activation is not one transaction. _ <- consents.flatMap(_._2.toList).foldLeft(Future.successful(())) { (previous, consent) => diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index f1f94beab6..42839fa44d 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -35,11 +35,12 @@ import code.api.util.Consent import code.consent.{ConsentStatus, Consents} import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketMemberMixInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} import code.bankconnectors.Connector +import code.consumer.Consumers import code.signingbaskets.SigningBasketX import code.users.Users import code.util.Helper.{MdcLoggable, booleanToFuture} import com.openbankproject.commons.model.enums.{ChallengeType, TransactionRequestTypes} -import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent} +import com.openbankproject.commons.model.{ChallengeTrait, SigningBasketContent, TransactionRequest, TransactionRequestId} import net.liftweb.common.{Box, Empty, Full} import scala.concurrent.Future @@ -161,16 +162,51 @@ object SigningBasketNewStyle extends MdcLoggable { _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { awaitingScaPaymentStatuses.contains(payment.status) } - } yield payment.on_behalf_of_user_id.flatMap(Consent.present).filter(isPerson(_, cc)) + } yield paymentPsu(payment, cc.consumer.map(_.key.get)) /** - * Whether this user is a person rather than the calling TPP's own pseudo-user. A client-credentials - * token resolves to an auto-created user keyed on the consumer's own key, and a payment lodged on one - * records it as the user it was made for; it names nobody a basket could be bound to. + * Whether this user is a person rather than the TPP's own pseudo-user. A client-credentials token + * resolves to an auto-created user keyed on the consumer's own key, and a payment lodged on one + * records it as a user it was made by or for; it names nobody a basket could be bound to. */ - private def isPerson(userId: String, cc: CallContext): Boolean = + private def isPerson(userId: String, tppConsumerKey: Option[String]): Boolean = Users.users.vend.getUserByUserId(userId).toOption - .forall(user => !cc.consumer.map(_.key.get).contains(user.idGivenByProvider)) + .forall(user => !tppConsumerKey.contains(user.idGivenByProvider)) + + /** + * The PSU a payment is for. A payment records two identities, the principal that lodged it and, when it + * was lodged for somebody, the one it was lodged for, and the rule that lets a caller address it + * accepts either (BerlinGroupPaymentAccess). So the PSU is whichever of the two is a person: the one it + * was lodged for if that is one, otherwise the one that lodged it. Reading only the first lets a payment + * a PSU lodged themselves join a basket that names nobody, to be bound to somebody else. + */ + private def paymentPsu(payment: TransactionRequest, tppConsumerKey: Option[String]): Option[String] = + List(payment.on_behalf_of_user_id, payment.user_id).flatten.flatMap(Consent.present).find(isPerson(_, tppConsumerKey)) + + /** + * The PSUs the members of a basket name. Empty when none names anyone, one when they agree. Read off the + * members as they are now, because they can change between creating the basket and authorising it. + */ + private def knownMemberPsus(basket: SigningBasketContent, callContext: Option[CallContext]): Future[Set[String]] = Future { + val tppKey = basket.basket.consumerId.flatMap(id => Consumers.consumers.vend.getConsumerByConsumerId(id).toOption.map(_.key.get)) + val payments = basket.payments.getOrElse(Nil).flatMap { id => + Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(id), callContext).toOption.flatMap(r => paymentPsu(r._1, tppKey)) + } + val consents = basket.consents.getOrElse(Nil).flatMap { id => + Consents.consentProvider.vend.getConsentByConsentId(id).toOption.flatMap(c => Consent.present(c.userId)) + } + (payments ++ consents).toSet + } + + /** + * Refuse a PSU the members do not all name. The PSU named when an authorisation is started, and the one + * the answer is checked as, must be the one every member that names a PSU is for; otherwise someone else + * could authorise a member that is not theirs. Answered like any other refusal to address the basket. + */ + def requireMembersForPsu(basket: SigningBasketContent, psuUserId: String, callContext: Option[CallContext]): Future[Unit] = + knownMemberPsus(basket, callContext).flatMap { named => + booleanToFuture(failMsg = SigningBasketNotFound, failCode = 403, cc = callContext)(named.forall(_ == psuUserId)).map(_ => ()) + } // A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked, // rejected or cancelled, or is being authorised some other way. @@ -249,6 +285,7 @@ object SigningBasketNewStyle extends MdcLoggable { if (reason == ConsentDoesNotMatchUser) (SigningBasketNotFound, 403) else (reason, 401) booleanToFuture(failMsg = failMsg, failCode = failCode, cc = callContext)(false).map(_ => "") } + _ <- requireMembersForPsu(basket, psuUserId, callContext) bound <- Future(SigningBasketX.signingBasketProvider.vend.bindSigningBasketPsu(basket.basket.basketId, psuUserId)) _ <- booleanToFuture(failMsg = SigningBasketNotFound, failCode = 403, cc = callContext)(bound.openOr(false)) } yield psuUserId diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 94368326aa..ef3d2e6188 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -1087,6 +1087,50 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } + feature("BG v1.3 signing baskets - the PSU of a member is the PSU of the basket") { + // A payment a PSU lodged themselves records them as the user that lodged it and nothing as the user it was + // lodged for. The rule that lets a TPP address the payment accepts either, so the PSU is read from both. + def paymentLodgedByPsu(): String = { + val payment = lodgePayment() + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, payment)).openOrThrowException("payment") + .mOnBehalfOfUserId("").saveMe() + payment + } + // A basket that names no PSU yet, as a client-credentials TPP's would be. + def basketWithoutPsu(payment: String): String = + SigningBasketX.signingBasketProvider.vend.createSigningBasket(Some(List(payment)), None, testConsumer.consumerId.get, None) + .openOrThrowException("basket").basketId + def startNamingPsu(basketId: String, psuName: String) = + makePostRequest(authorisationsUrl(basketId).POST <@ (clientCredentialsSession), "{}", List(("PSU-ID", psuName))) + + scenario("S1: another PSU cannot be bound to a basket whose payment was lodged by a PSU", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val basketId = basketWithoutPsu(paymentLodgedByPsu()) + expectRefusal(startNamingPsu(basketId, resourceUser2.name), 403, "RESOURCE_UNKNOWN", "naming a PSU the payment is not for") + storedChallengeCount(basketId) should equal(0) + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId).map(_.basket.psuUserId) should equal(net.liftweb.common.Full(None)) + withClue("the PSU the payment is for can start it: ") { startNamingPsu(basketId, resourceUser1.name).code should equal(201) } + } + + scenario("S1: a member that changes hands before the answer is not authorised by the PSU the authorisation was started for", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val payment = paymentLodgedByPsu() + val basketId = basketWithoutPsu(payment) + val started = startNamingPsu(basketId, resourceUser1.name) + started.code should equal(201) + val authorisationId = (started.body \\ "authorisationId").extract[String] + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, payment)).openOrThrowException("payment") + .mUserId(resourceUser2.userId).saveMe() + + expectRefusal(answerAuthorisation(basketId, authorisationId, as = clientCredentialsSession), 403, "RESOURCE_UNKNOWN", "answering for a PSU the payment is no longer for") + storedBasketStatus(basketId) should equal(Some("RCVD")) + storedPaymentStatus(payment) should equal(awaitingSca) + withClue("the answer was not consumed: ") { + Challenges.ChallengeProvider.vend.getChallenge(authorisationId).map(_.successful) should equal(net.liftweb.common.Full(false)) + } + } + } + // ───────────────────────── concurrency ───────────────────────── feature("BG v1.3 signing baskets - a delete racing the final answer has exactly one winner") { From 651b93b698cef51453ba073feb95e03d3626e91a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 15:07:34 +0200 Subject: [PATCH 33/40] fix: execute a payment stored INITIATED, as admission already accepts it Admission to a basket takes a payment stored RCVD or INITIATED, but the executor treated only RCVD as waiting for authorisation, so a member that was let in failed when its turn came. Both now read the same set. --- .../group/v1_3/SigningBasketExecution.scala | 6 ++++-- .../util/newstyle/SigningBasketNewStyle.scala | 4 ++-- .../v1_3/SigningBasketServiceSBSApiTest.scala | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index 60c29f1614..6a4a5789b8 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -29,6 +29,7 @@ package code.api.berlin.group.v1_3 import code.api.berlin.group.ConstantsBG import code.api.util.APIUtil.getPropsAsIntValue +import code.api.util.newstyle.SigningBasketNewStyle import code.api.util.{CallContext, Consent, NewStyle} import code.consent.{ConsentStatus, Consents} import code.signingbaskets.{SigningBasketMemberExecution, SigningBasketMemberState, SigningBasketX} @@ -72,7 +73,8 @@ object SigningBasketExecution extends MdcLoggable { /** How many times a member that failed is claimed again before it is left for an operator. */ private def maxAttempts: Int = getPropsAsIntValue("signing_basket_member_max_attempts", 3) - private val awaitingAuthorisation = "RCVD" + // The statuses a payment may have been admitted to a basket with. + private def awaitingAuthorisation: Set[String] = SigningBasketNewStyle.awaitingScaPaymentStatuses /** * Executes the basket's members that are not yet DONE, in order, stopping at the first that does not @@ -199,7 +201,7 @@ object SigningBasketExecution extends MdcLoggable { case Success((payment, _)) if bookedTransactionIds(payment) => // Already booked, by an earlier attempt that did not get to record it. finishWith(Done, s"Already booked: transaction ${payment.transaction_ids}") - case Success((payment, _)) if payment.status != awaitingAuthorisation => + case Success((payment, _)) if !awaitingAuthorisation.contains(payment.status) => finishWith(Failed, s"The payment is ${payment.status}, not waiting for authorisation") case Success((payment, _)) => book(basketId, member, payment, callContext, finishWith) } diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 42839fa44d..0262250e38 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -209,8 +209,8 @@ object SigningBasketNewStyle extends MdcLoggable { } // A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked, - // rejected or cancelled, or is being authorised some other way. - private val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED") + // rejected or cancelled, or is being authorised some other way. The executor accepts the same set. + val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED") /** * A consent may join a basket if the caller may address it under the rule consents use, it was diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index ef3d2e6188..eaa62459c9 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -967,6 +967,24 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { memberResults(basketId).map(r => r._1 -> (r._2, r._3)).toMap should equal(Map(good -> ("DONE", 1), bad -> ("FAILED", 3))) } + scenario("S4: a payment stored INITIATED is admitted and then booked like one stored RCVD, and ends COMPLETED", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val payment = lodgePayment() + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, payment)).openOrThrowException("payment") + .mStatus("INITIATED").saveMe() + val basketId = createBasket(List(payment)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + answerAuthorisation(basketId, authorisationId).code should equal(200) + balanceOf(ibanFrom) should equal(fromBefore - 2001) + balanceOf(ibanTo) should equal(toBefore + 2001) + storedPaymentStatus(payment) should equal("COMPLETED") + memberResults(basketId).map(r => (r._2, r._3)) should equal(List(("DONE", 1))) + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + scenario("S4: a member left UNKNOWN is reconciled by its transaction id, and is otherwise left alone", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { enableBasketAuthorisation() val ibanFrom = ibanAccounts.head From ba5fdf8be403b31850582141e97f12a316059940 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 6 Oct 2026 15:07:34 +0200 Subject: [PATCH 34/40] fix: mark a payment COMPLETED once the basket has booked it The payment routes record COMPLETED after booking; the basket executor recorded only its own member state. On a connector that does not set the status itself the payment stayed RCVD while its member was DONE and the basket ACTC, where the outdated-payment task could still reject it. Say COMPLETED after booking, and also where a payment is found already booked or is reconciled, since the status may be what an interrupted run missed. A failure to record it is logged and does not undo the booking. --- .../group/v1_3/SigningBasketExecution.scala | 25 ++++++++++++++++--- .../v1_3/SigningBasketServiceSBSApiTest.scala | 7 ++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index 6a4a5789b8..bd6a14a5bf 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -35,6 +35,7 @@ import code.consent.{ConsentStatus, Consents} import code.signingbaskets.{SigningBasketMemberExecution, SigningBasketMemberState, SigningBasketX} import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.enums.TransactionRequestStatus import com.openbankproject.commons.model.{AccountId, BankAccount, BankId, TransactionRequest, TransactionRequestId} import net.liftweb.common.Full @@ -184,6 +185,20 @@ object SigningBasketExecution extends MdcLoggable { private def claimableFrom(member: SigningBasketMemberExecution): Set[String] = if (member.attempts > 0 && member.attempts < maxAttempts) Set(Pending, Failed) else Set(Pending) + /** + * A booked payment is COMPLETED, as the payment routes leave it, so that it does not look like one still + * waiting for authorisation (the outdated-payment task rejects those). Failing to say so does not undo the + * booking, so it is logged and the member is still done; a later run says it again. + */ + private def markCompleted(paymentId: String, callContext: Option[CallContext]): Future[Unit] = + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(paymentId), TransactionRequestStatus.COMPLETED.toString, callContext) + .transform { + case Failure(error) => + logger.warn(s"Signing basket: payment $paymentId is booked but could not be marked COMPLETED: ${error.getMessage}") + Success(()) + case Success(_) => Success(()) + } + private def bookedTransactionIds(transactionRequest: TransactionRequest): Boolean = Option(transactionRequest.transaction_ids).exists(_.trim.nonEmpty) @@ -200,7 +215,7 @@ object SigningBasketExecution extends MdcLoggable { case Failure(_) => finishWith(Failed, "The payment cannot be read") case Success((payment, _)) if bookedTransactionIds(payment) => // Already booked, by an earlier attempt that did not get to record it. - finishWith(Done, s"Already booked: transaction ${payment.transaction_ids}") + markCompleted(member.memberId, callContext).flatMap(_ => finishWith(Done, s"Already booked: transaction ${payment.transaction_ids}")) case Success((payment, _)) if !awaitingAuthorisation.contains(payment.status) => finishWith(Failed, s"The payment is ${payment.status}, not waiting for authorisation") case Success((payment, _)) => book(basketId, member, payment, callContext, finishWith) @@ -219,13 +234,14 @@ object SigningBasketExecution extends MdcLoggable { case Success((fromAccount, _)) => val mapped = isMappedConnector(fromAccount, payment, callContext) NewStyle.function.createTransactionAfterChallengeV210(fromAccount, payment, callContext).transform(Success(_)).flatMap { - case Success(_) => finishWith(Done, "Booked") + case Success(_) => markCompleted(member.memberId, callContext).flatMap(_ => finishWith(Done, "Booked")) case Failure(error) => // The connector failed. Whether it booked first is read from the payment: a transaction id // means it did. Without one, the mapped connector is treated as not having booked, so the // payment can be tried again; any other connector may have, so it is left UNKNOWN. NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { - case Success((after, _)) if bookedTransactionIds(after) => finishWith(Done, s"Booked: transaction ${after.transaction_ids}") + case Success((after, _)) if bookedTransactionIds(after) => + markCompleted(member.memberId, callContext).flatMap(_ => finishWith(Done, s"Booked: transaction ${after.transaction_ids}")) case _ if mapped => finishWith(Failed, s"Booking failed: ${Option(error.getMessage).getOrElse(error.getClass.getSimpleName)}") case _ => finishWith(Unknown, s"The connector failed and may have booked: ${Option(error.getMessage).getOrElse(error.getClass.getSimpleName)}") } @@ -236,7 +252,8 @@ object SigningBasketExecution extends MdcLoggable { private def reconcile(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { case Success((payment, _)) if bookedTransactionIds(payment) => - record(basketId, member, Set(Unknown), Done, s"Reconciled: transaction ${payment.transaction_ids}").map(_ => true) + markCompleted(member.memberId, callContext).flatMap(_ => + record(basketId, member, Set(Unknown), Done, s"Reconciled: transaction ${payment.transaction_ids}").map(_ => true)) case _ => Future.successful(false) } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index eaa62459c9..9825c4dc9b 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -1003,9 +1003,16 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { List(booked, unbooked).foreach(id => provider.transitionSigningBasketMemberExecution(basketId, "payment", id, Set("DONE"), "UNKNOWN", "test")) MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, unbooked)).openOrThrowException("payment") .mStatus(awaitingSca).mTransactionIDs("").saveMe() + // The booked payment was never marked COMPLETED either (its transaction id is all that was recorded). + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, booked)).openOrThrowException("payment") + .mStatus(awaitingSca).saveMe() Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) withClue("nothing was booked again: ") { balanceOf(ibanFrom) should equal(afterBooking) } + withClue("a payment found booked is marked COMPLETED, so it no longer looks like one awaiting authorisation: ") { + storedPaymentStatus(booked) should equal("COMPLETED") + } + storedPaymentStatus(unbooked) should equal(awaitingSca) memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(booked -> "DONE", unbooked -> "UNKNOWN")) storedBasketStatusRaw(basketId) should equal("EXECUTION_INCOMPLETE") } From 2dc8c80a27234ba4d99e7770c32506075b6aea6a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 00:59:31 +0200 Subject: [PATCH 35/40] fix: never complete a basket that has nothing recorded to execute The members' execution rows were written after the basket was claimed and their result was ignored, and a basket with no rows counted as having every member done, so a run that stopped between the claim and the rows, or one whose rows could not be written, finished ACTC with nothing booked. Record the members from the basket itself whenever it is executed (the call is idempotent, so the resumption repairs such a basket), and treat a basket with no members as not complete. Also decide whether the booking connector is the mapped one from the connector actually in use: with a single configured connector the method routing table is not consulted, so a REST or RabbitMQ backend that timed out after booking was treated as mapped, recorded FAILED and booked again by the retry, instead of being left UNKNOWN. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 2 -- .../group/v1_3/SigningBasketExecution.scala | 35 +++++++++++++++---- .../v1_3/SigningBasketServiceSBSApiTest.scala | 19 ++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 9c5bbdc255..7ebe0d4825 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -596,8 +596,6 @@ This applies in the following scenarios: basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(claimed.openOr(false)) // Each member is recorded, then booked in order. The basket becomes ACTC only if every one is. - _ <- Future(provider.createSigningBasketMemberExecutions(basketId, - paymentIds.map(SigningBasketMemberState.PaymentType -> _) ::: consentIds.map(SigningBasketMemberState.ConsentType -> _))) allDone <- SigningBasketExecution.execute(basketId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge, executionIncomplete = !allDone) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index bd6a14a5bf..c50f9f4efb 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -31,6 +31,7 @@ import code.api.berlin.group.ConstantsBG import code.api.util.APIUtil.getPropsAsIntValue import code.api.util.newstyle.SigningBasketNewStyle import code.api.util.{CallContext, Consent, NewStyle} +import code.bankconnectors.{Connector, LocalMappedConnector, StarConnector} import code.consent.{ConsentStatus, Consents} import code.signingbaskets.{SigningBasketMemberExecution, SigningBasketMemberState, SigningBasketX} import code.util.Helper.MdcLoggable @@ -82,14 +83,27 @@ object SigningBasketExecution extends MdcLoggable { * finish. Returns true when every member is DONE and the basket has become ACTC. */ def execute(basketId: String, callContext: Option[CallContext]): Future[Boolean] = { + // A basket with nothing recorded to execute is not complete: every member is DONE only if there are members. def loop(rest: List[SigningBasketMemberExecution]): Future[Boolean] = rest match { case Nil => Future.successful(true) case member :: tail if member.state == Done => loop(tail) case member :: tail => executeMember(basketId, member, callContext).flatMap(done => if (done) loop(tail) else Future.successful(false)) } - loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) + if (ensureMembers(basketId)) loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) + else finish(basketId, allDone = false) } + /** + * Records the basket's members as PENDING if they are not recorded yet (the call is idempotent), and says + * whether the basket has any. Done here, rather than only when the answer arrives, so that a run that stopped + * before recording them is repaired by the resumption instead of finishing a basket with nothing in it. + */ + private def ensureMembers(basketId: String): Boolean = + provider.getSigningBasketByBasketId(basketId).toOption.exists { content => + val members = content.payments.getOrElse(Nil).map(PaymentType -> _) ::: content.consents.getOrElse(Nil).map(ConsentType -> _) + members.nonEmpty && provider.createSigningBasketMemberExecutions(basketId, members).openOr(false) + } + /** The PSU the basket was authorised by, bound when its authorisation was started. */ private def basketPsu(basketId: String): Option[String] = provider.getSigningBasketByBasketId(basketId).toOption.flatMap(_.basket.psuUserId) @@ -257,12 +271,21 @@ object SigningBasketExecution extends MdcLoggable { case _ => Future.successful(false) } - /** Whether the connector that books this payment is the mapped one. Only it is retried automatically. */ + /** + * Whether the connector that books this payment is the mapped one. Only it is retried automatically. + * With the star connector the method routing decides (none means mapped); with any other `connector` + * value that connector books everything, whatever the routing table holds. + */ private def isMappedConnector(fromAccount: BankAccount, payment: TransactionRequest, callContext: Option[CallContext]): Boolean = scala.util.Try { - code.bankconnectors.getConnectorNameAndMethodRouting( - "createTransactionAfterChallengeV210", - Array("fromAccount" -> fromAccount, "transactionRequest" -> payment, "callContext" -> callContext) - )._2 == "mapped" + Connector.connector.vend match { + case LocalMappedConnector => true + case StarConnector => + code.bankconnectors.getConnectorNameAndMethodRouting( + "createTransactionAfterChallengeV210", + Array("fromAccount" -> fromAccount, "transactionRequest" -> payment, "callContext" -> callContext) + )._2 == "mapped" + case _ => false + } }.getOrElse(false) } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 9825c4dc9b..704e9b3bca 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -967,6 +967,25 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { memberResults(basketId).map(r => r._1 -> (r._2, r._3)).toMap should equal(Map(good -> ("DONE", 1), bad -> ("FAILED", 3))) } + scenario("S4: a basket claimed before its members were recorded has them recorded and executed by the resumption, not completed empty", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val payment = lodgePayment() + val basketId = createBasket(List(payment)) + SigningBasketX.signingBasketProvider.vend.transitionSigningBasketStatus(basketId, "RCVD", "AUTHORISING") + memberResults(basketId) should equal(Nil) + val before = balanceOf(ibanFrom) + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(true) + balanceOf(ibanFrom) should equal(before - 2001) + storedPaymentStatus(payment) should equal("COMPLETED") + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + + scenario("S4: a basket with nothing to execute is never completed", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val empty = MappedSigningBasket.create.Status("AUTHORISING").ConsumerId("nobody").saveMe() + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(empty.basketId, None), 60.seconds) should be(false) + storedBasketStatusRaw(empty.basketId) should equal("EXECUTION_INCOMPLETE") + } + scenario("S4: a payment stored INITIATED is admitted and then booked like one stored RCVD, and ends COMPLETED", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { enableBasketAuthorisation() val ibanFrom = ibanAccounts.head From f6b2f0dc9510a534a4855de6642919d04820786c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 00:59:31 +0200 Subject: [PATCH 36/40] fix: accept only a finalised answer, and reject the basket when the answer fails for good The answer was accepted whenever the connector returned a challenge, but a connector can return the challenge itself with a failed status for a wrong one-time password. Only a challenge recorded as finalised authorises the basket now. When the answer fails for good (the connector reports failed, or the allowed attempts are used up) the basket is rejected with its payments and frees its members, as the payment authorisation does for a failed answer, so a basket cannot be given a fresh authorisation and a fresh allowance of guesses over and over. --- .../v1_3/Http4sBGv13SigningBaskets.scala | 50 +++++++++++++++---- .../v1_3/SigningBasketServiceSBSApiTest.scala | 18 +++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index 7ebe0d4825..dcf2539d51 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -517,6 +517,26 @@ This applies in the following scenarios: else if (message.contains("OBP-40016") || message.contains("OBP-20211") || message.contains("OBP-40014")) (message, 401) else (message, 400) + /** + * A basket whose authorisation failed for good is rejected, with the payments it held, and frees its + * members. Only one caller wins the move out of RCVD, so a basket that is being answered correctly at + * the same time is not rejected. + */ + private def rejectBasket(basketId: String, paymentIds: List[String], callContext: Option[CallContext]): Future[Unit] = { + val provider = SigningBasketX.signingBasketProvider.vend + Future(provider.transitionSigningBasketStatus( + basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.RJCT.toString).openOr(false)).flatMap { + case false => Future.successful(()) + case true => + provider.releaseSigningBasketMembers(basketId) + paymentIds.foldLeft(Future.successful(())) { (previous, id) => + previous.flatMap(_ => + NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(id), REJECTED.toString, callContext) + .map(_ => ()).recover { case _ => () }) + } + } + } + // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── // // Order matters, and nothing may be changed until the answer has been checked: @@ -581,16 +601,26 @@ This applies in the following scenarios: SuppliedAnswerType.PLAIN_TEXT_VALUE, callContext.map(_.copy(user = Full(psu))) ) - challenge <- Future { - boxedChallenge match { - case Full(answered) => answered - case failure => - val (message, code) = challengeFailure(failure match { - case f: Failure => f.msg - case _ => InvalidConnectorResponse - }) - unboxFullOrFail(Empty: Box[ChallengeTrait], callContext, message, code) - } + // Only an answer the challenge records as finalised authorises anything. A connector may hand back the + // challenge itself with a failed status, which is a refusal, not a success. + challenge <- boxedChallenge match { + case Full(answered) if answered.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised) => + Future.successful(answered) + case other => + // The answer failed for good (the connector says failed, or the attempts are used up): the basket + // is rejected, and so are its payments, so the same basket cannot be answered again with a new + // authorisation and a new allowance of guesses. + val failedForGood = other match { + case Full(answered) => answered.scaStatus.contains(StrongCustomerAuthenticationStatus.failed) + case f: Failure => f.msg.contains("OBP-40014") + case _ => false + } + val (message, code) = challengeFailure(other match { + case f: Failure => f.msg + case _ => InvalidChallengeAnswer + }) + (if (failedForGood) rejectBasket(basketId, paymentIds, callContext) else Future.successful(())) + .flatMap(_ => Future(unboxFullOrFail(Empty: Box[ChallengeTrait], callContext, message, code))) } claimed <- Future(provider.transitionSigningBasketStatus( basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 704e9b3bca..810ebe558d 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -986,6 +986,24 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { storedBasketStatusRaw(empty.basketId) should equal("EXECUTION_INCOMPLETE") } + scenario("S3: when the attempts are used up the basket is rejected with its payments, and cannot be answered again", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val started = startedBasket() + val wrong = """{"scaAuthenticationData":"definitely-wrong"}""" + val allowed = code.api.util.APIUtil.allowedAnswerTransactionRequestChallengeAttempts + val codes = (1 to allowed + 1).map(_ => answerAuthorisation(started.basketId, started.authorisationId, body = wrong).code) + withClue(s"codes $codes: ") { + codes.foreach(_ should equal(401)) + storedBasketStatus(started.basketId) should equal(Some("RJCT")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal("REJECTED")) + } + withClue("the right answer no longer authorises anything: ") { + val before = balanceOf(ibanAccounts.head) + answerAuthorisation(started.basketId, started.authorisationId).code should equal(409) + balanceOf(ibanAccounts.head) should equal(before) + } + } + scenario("S4: a payment stored INITIATED is admitted and then booked like one stored RCVD, and ends COMPLETED", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { enableBasketAuthorisation() val ibanFrom = ibanAccounts.head From c8341ed84fad7b703382bae5f13845de16f56da6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 00:59:31 +0200 Subject: [PATCH 37/40] fix: keep the basket resumption's time, queue and conflicts correct Take every timestamp the provider writes from the JVM, as the Mapper's own columns are, instead of the database clock: a database in another zone shifted the lease by hours, so a basket still being executed could be taken over or never be resumed. Move a basket that was looked at but did not finish behind the ones not yet tried. The resumption takes the oldest few unfinished baskets, and a stuck basket never moved, so enough of them kept every later basket from being resumed. Answer two requests that race for the same member with the same refusal whichever way the loser lost, including when the unique index rather than the check rejected it. --- .../group/v1_3/SigningBasketExecution.scala | 4 ++ .../MappedSigningBasketProvider.scala | 43 +++++++++++++++---- .../code/signingbaskets/SigningBasket.scala | 6 +++ .../MappedSigningBasketProviderTest.scala | 27 +++++++++++- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index c50f9f4efb..458222446e 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -121,6 +121,8 @@ object SigningBasketExecution extends MdcLoggable { previous.flatMap(_ => execute(basketId, None).transform { case Failure(error) => logger.error(s"Resuming the execution of signing basket $basketId failed", error) + // Out of the front of the queue, or a basket that always fails would hold its place for ever. + provider.touchSigningBasket(basketId) Success(false) case ok => ok }.map(_ => ())) @@ -139,6 +141,8 @@ object SigningBasketExecution extends MdcLoggable { completed } else { provider.transitionSigningBasketStatus(basketId, authorising, incomplete) + // A basket already incomplete does not move, but it was looked at: it goes behind the ones not yet tried. + provider.touchSigningBasket(basketId) false } } diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index 73390f42e6..6a3c688489 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -112,6 +112,8 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } result match { case Failure(_, Full(_: MemberAlreadyHeld), _) => Failure(SigningBasketMemberStatusInvalid) + // Two requests that both got past the check above: the unique index on the claim let one through. + case Failure(_, Full(error), _) if isConstraintViolation(error) => Failure(SigningBasketMemberStatusInvalid) case other => other } } @@ -140,6 +142,21 @@ object MappedSigningBasketProvider extends SigningBasketProvider { .map(row => SigningBasketMemberExecution( row.MemberType.get, row.MemberId.get, row.Position.get, row.State.get, Option(row.Detail.get).getOrElse(""), row.Attempts.get)) + // Every timestamp this provider writes or compares comes from the JVM, as the Mapper's own createdAt/updatedAt + // do. The database's CURRENT_TIMESTAMP is the database server's clock and zone, which need not be the JVM's. + private def now = new java.sql.Timestamp(System.currentTimeMillis) + + /** Whether the failure is a unique/integrity constraint violation (SQLState class 23), however deeply wrapped. */ + private def isConstraintViolation(error: Throwable): Boolean = { + def inChain(t: Throwable, depth: Int): Boolean = + t != null && depth < 10 && (t match { + case sql: java.sql.SQLException => + Option(sql.getSQLState).exists(_.startsWith("23")) || inChain(sql.getNextException, depth + 1) || inChain(sql.getCause, depth + 1) + case _ => inChain(t.getCause, depth + 1) + }) + inChain(error, 0) + } + override def transitionSigningBasketMemberExecution(basketId: String, memberType: String, memberId: String, @@ -153,10 +170,10 @@ object MappedSigningBasketProvider extends SigningBasketProvider { val attemptsSql = if (to == SigningBasketMemberState.Executing) s", ${m.Attempts._dbColumnNameLC} = ${m.Attempts._dbColumnNameLC} + 1" else "" DB.runUpdate( s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + - s"${m.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP$attemptsSql " + + s"${m.updatedAt._dbColumnNameLC} = ?$attemptsSql " + s"WHERE ${m.BasketId._dbColumnNameLC} = ? AND ${m.MemberType._dbColumnNameLC} = ? AND ${m.MemberId._dbColumnNameLC} = ? " + s"AND ${m.State._dbColumnNameLC} IN (${fromList.map(_ => "?").mkString(", ")})", - List[Any](to, detail.take(2000), basketId, memberType, memberId) ++ fromList) == 1 + List[Any](to, detail.take(2000), now, basketId, memberType, memberId) ++ fromList) == 1 } override def markStaleSigningBasketMembersUnknown(olderThanSeconds: Long): Box[Int] = @@ -165,9 +182,9 @@ object MappedSigningBasketProvider extends SigningBasketProvider { val cutoff = new java.sql.Timestamp(System.currentTimeMillis() - olderThanSeconds * 1000) DB.runUpdate( s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + - s"${m.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"${m.updatedAt._dbColumnNameLC} = ? " + s"WHERE ${m.State._dbColumnNameLC} = ? AND ${m.updatedAt._dbColumnNameLC} < ?", - List[Any](SigningBasketMemberState.Unknown, "The executor stopped before recording an outcome", SigningBasketMemberState.Executing, cutoff)) + List[Any](SigningBasketMemberState.Unknown, "The executor stopped before recording an outcome", now, SigningBasketMemberState.Executing, cutoff)) } override def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] = { @@ -187,19 +204,29 @@ object MappedSigningBasketProvider extends SigningBasketProvider { tryo { DB.runUpdate( s"UPDATE ${MappedSigningBasket.dbTableName} " + - s"SET ${MappedSigningBasket.Status._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"SET ${MappedSigningBasket.Status._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = ? " + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?", - List(to, basketId, from)) == 1 + List[Any](to, now, basketId, from)) == 1 + } + + override def touchSigningBasket(basketId: String): Box[Boolean] = + tryo { + val statuses = List(ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL) + DB.runUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} SET ${MappedSigningBasket.updatedAt._dbColumnNameLC} = ? " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? " + + s"AND ${MappedSigningBasket.Status._dbColumnNameLC} IN (${statuses.map(_ => "?").mkString(", ")})", + List[Any](now, basketId) ++ statuses) == 1 } override def bindSigningBasketPsu(basketId: String, psuUserId: String): Box[Boolean] = tryo { val bound = DB.runUpdate( s"UPDATE ${MappedSigningBasket.dbTableName} " + - s"SET ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"SET ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = ? " + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? " + s"AND (${MappedSigningBasket.PsuUserId._dbColumnNameLC} IS NULL OR ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = '')", - List(psuUserId, basketId)) == 1 + List[Any](psuUserId, now, basketId)) == 1 // Not bound by this call: that is only a success if the basket was already bound to this PSU. bound || MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basketId)).exists(_.psuUserId.contains(psuUserId)) } diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index d3e7602ece..a56812acc3 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -113,6 +113,12 @@ trait SigningBasketProvider extends MdcLoggable { */ def markStaleSigningBasketMembersUnknown(olderThanSeconds: Long): Box[Int] + /** + * Records that someone looked at an unfinished basket, so it goes to the back of the queue + * `getSigningBasketsAwaitingExecution` reads, instead of staying at the front for as long as it is stuck. + */ + def touchSigningBasket(basketId: String): Box[Boolean] + /** Baskets whose execution has not finished, oldest first: AUTHORISING or EXECUTION_INCOMPLETE. */ def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] diff --git a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala index a5d56a6944..0cc3d6df41 100644 --- a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -149,10 +149,16 @@ class MappedSigningBasketProviderTest extends ServerSetup { import scala.concurrent.ExecutionContext.Implicits.global (1 to 10).foreach { round => val member = uuid() - val callers = (1 to 6).map(_ => Future(provider.createSigningBasket(Some(List(member)), None, "consumer-1", None).isDefined)) - val created = Await.result(Future.sequence(callers), 60.seconds) + val callers = (1 to 6).map(_ => Future(provider.createSigningBasket(Some(List(member)), None, "consumer-1", None))) + val results = Await.result(Future.sequence(callers), 60.seconds) + val created = results.map(_.isDefined) withClue(s"round $round: ") { created.count(identity) should equal(1) + // Whichever way a loser lost (the check, or the unique index under it), it is the same refusal. + results.filterNot(_.isDefined).foreach { + case net.liftweb.common.Failure(message, _, _) => message should equal(code.api.util.ErrorMessages.SigningBasketMemberStatusInvalid) + case other => fail(s"unexpected result $other") + } MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.PaymentId, member)).size should equal(1) } } @@ -216,6 +222,23 @@ class MappedSigningBasketProviderTest extends ServerSetup { Map(stuck -> Unknown, finished -> Done)) } + scenario("a basket that was looked at goes behind the ones not yet tried, so a stuck one does not hold the queue") { + val looked = newBasket() + val waiting = newBasket() + List(looked, waiting).foreach(b => + provider.transitionSigningBasketStatus(b.basketId, "RCVD", code.api.berlin.group.ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) + Thread.sleep(1200) + provider.touchSigningBasket(looked.basketId).openOrThrowException("x") should be(true) + val awaiting = provider.getSigningBasketsAwaitingExecution(1, 100) + awaiting should contain(waiting.basketId) + awaiting should not contain looked.basketId + withClue("a basket that is not awaiting execution is not touched: ") { + val finished = newBasket() + provider.transitionSigningBasketStatus(finished.basketId, "RCVD", "ACTC") + provider.touchSigningBasket(finished.basketId).openOrThrowException("x") should be(false) + } + } + scenario("baskets whose execution has not finished are listed, oldest first") { val stuck = newBasket() val done = newBasket() From ed831e8306c5a7921e68a0684670aae3d0cd3a5c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 08:24:29 +0200 Subject: [PATCH 38/40] fix: commit the execution ledger on its own connection, and end a basket that cannot progress A request's database work is one transaction that commits when the response is sent. The claim of a basket, the member states and the release of its members were written inside it, so a node that died after a remote connector had booked a payment and before the response lost all of it: the basket was RCVD again, nothing said a booking was under way, and the same answer could be sent once more. Write the ledger on a connection taken from the pool directly and commit it at once. Nothing else the request writes touches those rows, so the two connections do not wait for each other. With the ledger surviving, a member left UNKNOWN on the mapped connector has no transaction id exactly when its booking did not commit, so the resumption now records it as FAILED and claims it again; other connectors still leave it for an operator. Add the end an incomplete basket never had: when every member that is not done has failed as often as it is allowed to, the basket is EXECUTION_FAILED (reported RCVD), is no longer picked up, and frees what it held. Run the reads of an execution inside a Future, so that an exception from one basket is that basket's failure and the resumption still reaches the others and moves the failing one to the back of the queue. Correct the comment on how a refused creation is rolled back. --- docs/signing_basket_operations.md | 32 ++++- .../code/api/berlin/group/ConstantsBG.scala | 9 +- .../group/v1_3/SigningBasketExecution.scala | 71 +++++++--- .../MappedSigningBasketProvider.scala | 128 ++++++++++++++---- .../code/signingbaskets/SigningBasket.scala | 3 + .../v1_3/SigningBasketServiceSBSApiTest.scala | 80 ++++++++++- .../MappedSigningBasketProviderTest.scala | 32 +++++ 7 files changed, 307 insertions(+), 48 deletions(-) diff --git a/docs/signing_basket_operations.md b/docs/signing_basket_operations.md index 467b9192fc..10ddd5ca1d 100644 --- a/docs/signing_basket_operations.md +++ b/docs/signing_basket_operations.md @@ -20,11 +20,15 @@ Related properties: ## What the stored status means -A basket reports `RCVD`, `PATC`, `ACTC`, `CANC` or `RJCT`. Two more are stored and reported as `RCVD`: +A basket reports `RCVD`, `PATC`, `ACTC`, `CANC` or `RJCT`. Three more are stored and reported as `RCVD`: * `AUTHORISING`: the authorisation was answered correctly and the basket was claimed; its members are being executed. * `EXECUTION_INCOMPLETE`: execution stopped with a member that is not `DONE`. +* `EXECUTION_FAILED`: the end of an incomplete basket. Every member that is not `DONE` has failed as often as it + is allowed to (`signing_basket_member_max_attempts`), so running it again would change nothing. The basket is no + longer picked up and what it held is free; it cannot be authorised again, cancelled or restarted. The creating + TPP still reads what happened from the results endpoint. `ACTC` means every member is `DONE`. Each member has its own state in `SigningBasketMemberExecution`, and the creating TPP reads it from `GET /signing-baskets/{basketId}/execution`. @@ -35,10 +39,24 @@ creating TPP reads it from `GET /signing-baskets/{basketId}/execution`. | `EXECUTING` | Claimed by an executor. Past the lease it becomes `UNKNOWN`. | | `DONE` | Payment booked, or consent activated. | | `FAILED` | Refused before it took effect. Claimed again automatically, on the mapped connector, up to the attempts allowed. | -| `UNKNOWN` | The executor stopped without recording an outcome, or a connector other than the mapped one failed after it may have booked. | +| `UNKNOWN` | The executor stopped without recording an outcome, or a connector other than the mapped one failed after it may have booked. On the mapped connector the resumption turns it into `DONE` (the payment has a transaction id) or `FAILED` (it has not). | Several payments in one basket are not one transaction. A failure leaves the earlier payments booked. +### What is committed when + +The ledger (the claim `RCVD -> AUTHORISING`, the member rows and their states, the release of members) is written on +a database connection of its own and committed at once. Everything else a request writes (the finalised challenge, +the booking and transaction id on the mapped connector, the payment status) is committed when the response is sent. +So if a node dies in the middle of answering an authorisation, the ledger survives and says what was under way, and +the basket is not answered a second time: it is `AUTHORISING`, not `RCVD`. A request that executes a basket holds two +connections from the pool for that time. + +On the mapped connector a booking that did not commit leaves no transaction id, so the resumption knows that member +was not booked (it becomes `FAILED` and is claimed again). On any other connector there is no way to know, and the +member stays `UNKNOWN` for you. The lease (`signing_basket_execution_lease_in_seconds`) must be longer than the +longest an answer to an authorisation can take, or a request still working can have its members taken over. + ## Looking at baskets that did not complete ```sql @@ -58,7 +76,9 @@ ORDER BY position; ## Members left UNKNOWN The resumption reconciles an `UNKNOWN` payment by its transaction id: if the payment carries one it was -booked, and the member becomes `DONE`. Without one, nothing proves whether it was booked, so it is left for you. +booked, and the member becomes `DONE`. Without one, the mapped connector did not book it (it records the +transaction id in the same transaction as the booking), so the member becomes `FAILED` and is claimed again. On any +other connector nothing proves whether it was booked, so it is left for you. 1. Find the payment's debit in the ledger (the debtor account, the amount, the time of the execution). 2. If it was booked, set the payment's transaction id and mark the member `DONE`; the next resumption completes @@ -101,6 +121,12 @@ payment. A basket that is only to be closed needs none of this; set its status t ## Things that can still surprise +* A payment that an active basket holds cannot be authorised on its own (the payment authorisation answers 409 + `STATUS_INVALID`), and a payment that is no longer waiting for SCA (rejected, cancelled, failed) stops the answer to + the basket's authorisation with 409 before anything is booked. +* Wrong answers are counted for the basket, over all its authorisations, against + `answer_transactionRequest_challenge_allowed_attempts`. The answer that uses the allowance up rejects the basket + (`RJCT`) and its payments, so starting new authorisations does not give new guesses. * A payment waiting in a basket is still a payment waiting for SCA: if `berlin_group_outdated_transactions_interval_in_seconds` is set, the outdated-payment task rejects it after `berlin_group_outdated_transactions_time_in_seconds`, and the basket's member then fails as not waiting for diff --git a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala index ee929d7bcc..e8326e0d57 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala @@ -62,7 +62,14 @@ object ConstantsBG { */ val EXECUTION_INCOMPLETE_INTERNAL = "EXECUTION_INCOMPLETE" + /** + * Stored when execution stopped and nothing that is left can be finished by running it again: every member + * that is not done has failed as often as it is allowed to. The basket is over, what it held is free, + * and it is no longer picked up. Reported as RCVD; the members' own results say what happened. + */ + val EXECUTION_FAILED_INTERNAL = "EXECUTION_FAILED" + def external(storedStatus: String): String = - if (storedStatus == AUTHORISING_INTERNAL || storedStatus == EXECUTION_INCOMPLETE_INTERNAL) RCVD.toString else storedStatus + if (storedStatus == AUTHORISING_INTERNAL || storedStatus == EXECUTION_INCOMPLETE_INTERNAL || storedStatus == EXECUTION_FAILED_INTERNAL) RCVD.toString else storedStatus } } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala index 458222446e..a167be1951 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -83,14 +83,18 @@ object SigningBasketExecution extends MdcLoggable { * finish. Returns true when every member is DONE and the basket has become ACTC. */ def execute(basketId: String, callContext: Option[CallContext]): Future[Boolean] = { - // A basket with nothing recorded to execute is not complete: every member is DONE only if there are members. def loop(rest: List[SigningBasketMemberExecution]): Future[Boolean] = rest match { case Nil => Future.successful(true) case member :: tail if member.state == Done => loop(tail) case member :: tail => executeMember(basketId, member, callContext).flatMap(done => if (done) loop(tail) else Future.successful(false)) } - if (ensureMembers(basketId)) loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) - else finish(basketId, allDone = false) + // Inside a Future from the first line, so that whatever the reads below throw is this basket's failure, + // handled where the caller handles a failed execution, and not an exception that skips it. + Future.unit.flatMap { _ => + // A basket with nothing recorded to execute is not complete: every member is DONE only if there are members. + if (ensureMembers(basketId)) loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) + else finish(basketId, allDone = false) + } } /** @@ -114,24 +118,27 @@ object SigningBasketExecution extends MdcLoggable { * it stopped. Safe to run on several nodes at once, since each member and each status change is claimed * with a conditional update. Returns how many baskets were looked at. */ - def resumePending(leaseSeconds: Long, limit: Int): Future[Int] = { - provider.markStaleSigningBasketMembersUnknown(leaseSeconds) - val baskets = provider.getSigningBasketsAwaitingExecution(leaseSeconds, limit) - baskets.foldLeft(Future.successful(())) { (previous, basketId) => - previous.flatMap(_ => execute(basketId, None).transform { - case Failure(error) => - logger.error(s"Resuming the execution of signing basket $basketId failed", error) - // Out of the front of the queue, or a basket that always fails would hold its place for ever. - provider.touchSigningBasket(basketId) - Success(false) - case ok => ok - }.map(_ => ())) - }.map(_ => baskets.size) - } + def resumePending(leaseSeconds: Long, limit: Int): Future[Int] = + Future.unit.flatMap { _ => + provider.markStaleSigningBasketMembersUnknown(leaseSeconds) + val baskets = provider.getSigningBasketsAwaitingExecution(leaseSeconds, limit) + baskets.foldLeft(Future.successful(())) { (previous, basketId) => + // Each basket's failure stays its own: the ones after it are still resumed. + previous.flatMap(_ => execute(basketId, None).transform { + case Failure(error) => + logger.error(s"Resuming the execution of signing basket $basketId failed", error) + // Out of the front of the queue, or a basket that always fails would hold its place for ever. + scala.util.Try(provider.touchSigningBasket(basketId)) + Success(false) + case ok => ok + }.map(_ => ())) + }.map(_ => baskets.size) + } private def finish(basketId: String, allDone: Boolean): Future[Boolean] = Future { val authorising = ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL val incomplete = ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL + val failed = ConstantsBG.SigningBasketsStatus.EXECUTION_FAILED_INTERNAL val actc = ConstantsBG.SigningBasketsStatus.ACTC.toString if (allDone) { val completed = provider.transitionSigningBasketStatus(basketId, authorising, actc).openOr(false) || @@ -139,6 +146,12 @@ object SigningBasketExecution extends MdcLoggable { // The members are free to join another basket only once the basket is final. if (completed) provider.releaseSigningBasketMembers(basketId) completed + } else if (cannotProgress(basketId)) { + // What is left needs a person, not another run: the basket ends, and what it held is free to be used again. + val ended = provider.transitionSigningBasketStatus(basketId, authorising, failed).openOr(false) || + provider.transitionSigningBasketStatus(basketId, incomplete, failed).openOr(false) + if (ended) provider.releaseSigningBasketMembers(basketId) + false } else { provider.transitionSigningBasketStatus(basketId, authorising, incomplete) // A basket already incomplete does not move, but it was looked at: it goes behind the ones not yet tried. @@ -147,6 +160,15 @@ object SigningBasketExecution extends MdcLoggable { } } + /** + * True when every member that is not DONE has failed as often as it is allowed to. Nothing started, running, + * of unknown outcome or still to be retried is left, so another run would change nothing. + */ + private def cannotProgress(basketId: String): Boolean = { + val pending = provider.getSigningBasketMemberExecutions(basketId).filterNot(_.state == Done) + pending.nonEmpty && pending.forall(member => member.state == Failed && member.attempts >= maxAttempts) + } + private def executeMember(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = member.memberType match { case PaymentType => executePayment(basketId, member, callContext) @@ -266,12 +288,25 @@ object SigningBasketExecution extends MdcLoggable { } } - /** A member left UNKNOWN is DONE if its payment carries a transaction id, and is otherwise left for an operator. */ + /** + * A member left UNKNOWN is DONE if its payment carries a transaction id. Without one, the mapped connector + * did not book it: it books and records the transaction id in the request's own database transaction, which + * did not commit, so the payment is FAILED and will be claimed again. Any other connector may have booked it + * without leaving a trace, so it is left for an operator. + */ private def reconcile(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { case Success((payment, _)) if bookedTransactionIds(payment) => markCompleted(member.memberId, callContext).flatMap(_ => record(basketId, member, Set(Unknown), Done, s"Reconciled: transaction ${payment.transaction_ids}").map(_ => true)) + case Success((payment, _)) => + NewStyle.function.checkBankAccountExists(BankId(payment.from.bank_id), AccountId(payment.from.account_id), callContext) + .transform(Success(_)).flatMap { + case Success((fromAccount, _)) if isMappedConnector(fromAccount, payment, callContext) => + record(basketId, member, Set(Unknown), Failed, "Not booked: the mapped connector books inside the request's transaction, which did not commit") + .map(_ => false) + case _ => Future.successful(false) + } case _ => Future.successful(false) } diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index 6a3c688489..a23e6fb71b 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -67,9 +67,14 @@ object MappedSigningBasketProvider extends SigningBasketProvider { consumerId: String, psuUserId: Option[String] ): Box[SigningBasketTrait] = { - // The basket and every member row are written inside one DB.use. Inside an HTTP request the - // connection is the request's own, whose rollback is not ours to call, so a failure part way is - // also undone by hand: nothing of a basket that was not fully created is left behind. + // The basket and every member row are written inside one DB.use. Outside a request a failure part way + // rolls all of it back. Inside an HTTP request the connection is the request's own, whose rollback is + // not ours to call, so what was written is deleted again by hand where the database lets the + // transaction go on. PostgreSQL does not: a failed statement (a unique-index violation, say) aborts the + // transaction, the deletes fail as well, and it is the request's own commit of the aborted transaction, + // which PostgreSQL turns into a rollback, that leaves nothing of the basket behind. That rollback takes + // everything else the request wrote with it (an idempotency record, for one), and the refusal is + // still answered. var created: Option[MappedSigningBasket] = None val memberKeys = paymentIds.getOrElse(Nil).map(id => s"payment:$id") ::: consentIds.getOrElse(Nil).map(id => s"consent:$id") @@ -120,27 +125,93 @@ object MappedSigningBasketProvider extends SigningBasketProvider { override def createSigningBasketMemberExecutions(basketId: String, members: List[(String, String)]): Box[Boolean] = tryo { - DB.use(DefaultConnectionIdentifier) { _ => - members.zipWithIndex.foreach { case ((memberType, memberId), position) => - val exists = MappedSigningBasketMemberExecution.find( - By(MappedSigningBasketMemberExecution.BasketId, basketId), - By(MappedSigningBasketMemberExecution.MemberType, memberType), - By(MappedSigningBasketMemberExecution.MemberId, memberId)).isDefined - if (!exists) - MappedSigningBasketMemberExecution.create - .BasketId(basketId).MemberType(memberType).MemberId(memberId) - .Position(position).State(SigningBasketMemberState.Pending).Detail("").Attempts(0) - .saveMe() + val m = MappedSigningBasketMemberExecution + members.zipWithIndex.foreach { case ((memberType, memberId), position) => + // One ledger transaction per member: a member recorded by an executor racing this one makes the insert + // violate the unique index, which only says it is there already. + try { + inLedger { connection => + val recorded = queryLedger(connection, + s"SELECT COUNT(*) FROM ${m.dbTableName} WHERE ${m.BasketId._dbColumnNameLC} = ? AND ${m.MemberType._dbColumnNameLC} = ? AND ${m.MemberId._dbColumnNameLC} = ?", + List(basketId, memberType, memberId))(_.getInt(1)).headOption.getOrElse(0) > 0 + if (!recorded) + updateLedger(connection, + s"INSERT INTO ${m.dbTableName} (${m.BasketId._dbColumnNameLC}, ${m.MemberType._dbColumnNameLC}, ${m.MemberId._dbColumnNameLC}, " + + s"${m.Position._dbColumnNameLC}, ${m.State._dbColumnNameLC}, ${m.Detail._dbColumnNameLC}, ${m.Attempts._dbColumnNameLC}, " + + s"${m.createdAt._dbColumnNameLC}, ${m.updatedAt._dbColumnNameLC}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + List[Any](basketId, memberType, memberId, position, SigningBasketMemberState.Pending, "", 0, now, now)) + } + } catch { + case error: Throwable if isConstraintViolation(error) => () } } true } - override def getSigningBasketMemberExecutions(basketId: String): List[SigningBasketMemberExecution] = - MappedSigningBasketMemberExecution - .findAll(By(MappedSigningBasketMemberExecution.BasketId, basketId), OrderBy(MappedSigningBasketMemberExecution.Position, Ascending)) - .map(row => SigningBasketMemberExecution( - row.MemberType.get, row.MemberId.get, row.Position.get, row.State.get, Option(row.Detail.get).getOrElse(""), row.Attempts.get)) + override def getSigningBasketMemberExecutions(basketId: String): List[SigningBasketMemberExecution] = { + val m = MappedSigningBasketMemberExecution + inLedger { connection => + queryLedger(connection, + s"SELECT ${m.MemberType._dbColumnNameLC}, ${m.MemberId._dbColumnNameLC}, ${m.Position._dbColumnNameLC}, ${m.State._dbColumnNameLC}, " + + s"${m.Detail._dbColumnNameLC}, ${m.Attempts._dbColumnNameLC} FROM ${m.dbTableName} WHERE ${m.BasketId._dbColumnNameLC} = ? " + + s"ORDER BY ${m.Position._dbColumnNameLC}", + List(basketId))(row => SigningBasketMemberExecution( + row.getString(1), row.getString(2), row.getInt(3), row.getString(4), Option(row.getString(5)).getOrElse(""), row.getInt(6))) + } + } + + /** + * Runs `work` on a connection of its own and commits it before returning, whatever request it is called from. + * + * The execution ledger is the record of what was done to the outside world: that a basket was claimed, that + * a member was being executed, that it finished. An HTTP request's database work is one transaction that + * commits only when the response is sent, so a ledger written inside it is lost together with it when the + * node dies after a remote connector has booked a payment but before the response: the basket would be back + * to RCVD, nothing would say a booking was under way, and the same answer could be sent again. + * + * It takes a connection from the pool directly, not through the connection manager, which would hand out + * the request's own connection. A request therefore holds two connections while it executes a basket. + * The ledger rows are written only through here, so the request's connection never holds a lock on them. + */ + private def inLedger[A](work: java.sql.Connection => A): A = { + val connection = code.api.util.APIUtil.vendor.newConnection(DefaultConnectionIdentifier) + .openOrThrowException("No database connection could be taken for the signing basket ledger") + try { + connection.setAutoCommit(false) + val result = work(connection) + connection.commit() + result + } catch { + case error: Throwable => + try connection.rollback() catch { case _: Exception => () } + throw error + } finally { + try connection.close() catch { case _: Exception => () } + } + } + + private def updateLedger(connection: java.sql.Connection, sql: String, params: List[Any]): Int = { + val statement = connection.prepareStatement(sql) + try { + params.zipWithIndex.foreach { case (value, index) => statement.setObject(index + 1, value) } + statement.executeUpdate() + } finally statement.close() + } + + private def queryLedger[A](connection: java.sql.Connection, sql: String, params: List[Any])(read: java.sql.ResultSet => A): List[A] = { + val statement = connection.prepareStatement(sql) + try { + params.zipWithIndex.foreach { case (value, index) => statement.setObject(index + 1, value) } + val rows = statement.executeQuery() + try { + val buffer = scala.collection.mutable.ListBuffer.empty[A] + while (rows.next()) buffer += read(rows) + buffer.toList + } finally rows.close() + } finally statement.close() + } + + private def ledgerUpdate(sql: String, params: List[Any]): Int = inLedger(updateLedger(_, sql, params)) // Every timestamp this provider writes or compares comes from the JVM, as the Mapper's own createdAt/updatedAt // do. The database's CURRENT_TIMESTAMP is the database server's clock and zone, which need not be the JVM's. @@ -168,7 +239,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider { val fromList = from.toList // A claim is the move to EXECUTING, and counts as an attempt. val attemptsSql = if (to == SigningBasketMemberState.Executing) s", ${m.Attempts._dbColumnNameLC} = ${m.Attempts._dbColumnNameLC} + 1" else "" - DB.runUpdate( + ledgerUpdate( s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + s"${m.updatedAt._dbColumnNameLC} = ?$attemptsSql " + s"WHERE ${m.BasketId._dbColumnNameLC} = ? AND ${m.MemberType._dbColumnNameLC} = ? AND ${m.MemberId._dbColumnNameLC} = ? " + @@ -180,7 +251,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider { tryo { val m = MappedSigningBasketMemberExecution val cutoff = new java.sql.Timestamp(System.currentTimeMillis() - olderThanSeconds * 1000) - DB.runUpdate( + ledgerUpdate( s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + s"${m.updatedAt._dbColumnNameLC} = ? " + s"WHERE ${m.State._dbColumnNameLC} = ? AND ${m.updatedAt._dbColumnNameLC} < ?", @@ -197,12 +268,21 @@ object MappedSigningBasketProvider extends SigningBasketProvider { ).map(_.basketId) } + // Through the ledger, like the status change it goes with: if the status were committed and the release lost + // with a request, the basket would be final and still hold its members. override def releaseSigningBasketMembers(basketId: String): Box[Boolean] = - tryo { MappedSigningBasketMemberClaim.bulkDelete_!!(By(MappedSigningBasketMemberClaim.BasketId, basketId)) } + tryo { + val claims = MappedSigningBasketMemberClaim + ledgerUpdate(s"DELETE FROM ${claims.dbTableName} WHERE ${claims.BasketId._dbColumnNameLC} = ?", List(basketId)) + true + } + + override def memberHeldByBasket(memberKey: String): Boolean = + MappedSigningBasketMemberClaim.find(By(MappedSigningBasketMemberClaim.MemberKey, memberKey)).isDefined override def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] = tryo { - DB.runUpdate( + ledgerUpdate( s"UPDATE ${MappedSigningBasket.dbTableName} " + s"SET ${MappedSigningBasket.Status._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = ? " + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?", @@ -212,7 +292,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider { override def touchSigningBasket(basketId: String): Box[Boolean] = tryo { val statuses = List(ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL) - DB.runUpdate( + ledgerUpdate( s"UPDATE ${MappedSigningBasket.dbTableName} SET ${MappedSigningBasket.updatedAt._dbColumnNameLC} = ? " + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? " + s"AND ${MappedSigningBasket.Status._dbColumnNameLC} IN (${statuses.map(_ => "?").mkString(", ")})", diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index a56812acc3..97d1800aef 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -122,6 +122,9 @@ trait SigningBasketProvider extends MdcLoggable { /** Baskets whose execution has not finished, oldest first: AUTHORISING or EXECUTION_INCOMPLETE. */ def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] + /** Whether an active basket holds the member, named "payment:" or "consent:". */ + def memberHeldByBasket(memberKey: String): Boolean + /** * Frees the payments and consents a basket was holding, so they can join another basket. Called when * a basket reaches a final status. diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 810ebe558d..5babbfbbba 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -1022,7 +1022,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { storedBasketStatus(basketId) should equal(Some("ACTC")) } - scenario("S4: a member left UNKNOWN is reconciled by its transaction id, and is otherwise left alone", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + scenario("S4: a member left UNKNOWN is reconciled by its transaction id, and on the mapped connector is otherwise known not to have been booked", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { enableBasketAuthorisation() val ibanFrom = ibanAccounts.head val booked = lodgePayment() @@ -1050,7 +1050,9 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { storedPaymentStatus(booked) should equal("COMPLETED") } storedPaymentStatus(unbooked) should equal(awaitingSca) - memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(booked -> "DONE", unbooked -> "UNKNOWN")) + withClue("on the mapped connector the booking is in the request's own transaction, so a payment without a transaction id was rolled back with it: ") { + memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(booked -> "DONE", unbooked -> "FAILED")) + } storedBasketStatusRaw(basketId) should equal("EXECUTION_INCOMPLETE") } } @@ -1214,4 +1216,78 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } } } + + feature("BG v1.3 signing baskets - what a review of the execution found") { + scenario("R6: a basket whose execution throws does not stop the resumption reaching the others, and goes to the back of the queue", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val provider = code.signingbaskets.MappedSigningBasketProvider + val poisoned = createBasket(List(lodgePayment())) + Thread.sleep(30) + val good = lodgePayment() + val goodBasket = createBasket(List(good)) + List(poisoned, goodBasket).foreach(id => provider.transitionSigningBasketStatus(id, "RCVD", "AUTHORISING")) + val poisonedBefore = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, poisoned)).openOrThrowException("basket").updatedAt.get.getTime + val before = balanceOf(ibanFrom) + Thread.sleep(30) + SigningBasketX.signingBasketProvider.default.set(new ThrowingForOneBasket(provider, poisoned)) + try { + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(0, 10), 60.seconds) + } finally { + SigningBasketX.signingBasketProvider.default.set(provider) + } + withClue("the basket after the poisoned one was still executed: ") { + balanceOf(ibanFrom) should equal(before - 2001) + storedBasketStatus(goodBasket) should equal(Some("ACTC")) + } + withClue("the poisoned basket moved back instead of keeping the head of the queue: ") { + MappedSigningBasket.find(By(MappedSigningBasket.BasketId, poisoned)).openOrThrowException("basket").updatedAt.get.getTime should be > poisonedBefore + } + } + + scenario("R4: when no member can make progress without an operator the basket ends, and frees what it held", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val good = lodgePayment() + val bad = lodgePaymentThatCannotBeBooked() + val basketId = createBasket(List(good, bad)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId).code should equal(200) + (1 to 4).foreach { _ => + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + } + memberResults(basketId).map(r => r._1 -> (r._2, r._3)).toMap should equal(Map(good -> ("DONE", 1), bad -> ("FAILED", 3))) + withClue("a terminal state, still reported as RCVD: ") { + storedBasketStatusRaw(basketId) should equal("EXECUTION_FAILED") + (makeGetRequest((basketUrl(basketId) / "status").GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + } + withClue("the resumption has nothing more to do with it: ") { + code.signingbaskets.MappedSigningBasketProvider.getSigningBasketsAwaitingExecution(0, 1000) should not contain basketId + } + withClue("what it held is free: ") { + code.signingbaskets.MappedSigningBasketMemberClaim.find(By(code.signingbaskets.MappedSigningBasketMemberClaim.MemberKey, s"payment:$bad")).isDefined should be(false) + postBasket(s"""{"paymentIds":${idList(List(bad))}}""").code should equal(201) + } + expectRefusal(startAuthorisation(basketId), 409, "STATUS_INVALID", "a new authorisation on a basket that ended") + expectRefusal(makeDeleteRequest(basketUrl(basketId).DELETE <@ (user1)), 409, "STATUS_INVALID", "deleting a basket that ended") + } + } +} + +/** A provider that behaves as the real one, except that reading one particular basket throws, as a database failure would. */ +private class ThrowingForOneBasket(underlying: code.signingbaskets.SigningBasketProvider, poisoned: String) extends code.signingbaskets.SigningBasketProvider { + override def getSigningBaskets() = underlying.getSigningBaskets() + override def getSigningBasketByBasketId(entityId: String) = + if (entityId == poisoned) throw new RuntimeException("the database is not answering") else underlying.getSigningBasketByBasketId(entityId) + override def createSigningBasket(paymentIds: Option[List[String]], consentIds: Option[List[String]], consumerId: String, psuUserId: Option[String]) = + underlying.createSigningBasket(paymentIds, consentIds, consumerId, psuUserId) + override def createSigningBasketMemberExecutions(basketId: String, members: List[(String, String)]) = underlying.createSigningBasketMemberExecutions(basketId, members) + override def getSigningBasketMemberExecutions(basketId: String) = underlying.getSigningBasketMemberExecutions(basketId) + override def transitionSigningBasketMemberExecution(basketId: String, memberType: String, memberId: String, from: Set[String], to: String, detail: String) = + underlying.transitionSigningBasketMemberExecution(basketId, memberType, memberId, from, to, detail) + override def markStaleSigningBasketMembersUnknown(olderThanSeconds: Long) = underlying.markStaleSigningBasketMembersUnknown(olderThanSeconds) + override def touchSigningBasket(basketId: String) = underlying.touchSigningBasket(basketId) + override def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int) = underlying.getSigningBasketsAwaitingExecution(olderThanSeconds, limit) + override def releaseSigningBasketMembers(basketId: String) = underlying.releaseSigningBasketMembers(basketId) + override def memberHeldByBasket(memberKey: String) = underlying.memberHeldByBasket(memberKey) + override def transitionSigningBasketStatus(basketId: String, from: String, to: String) = underlying.transitionSigningBasketStatus(basketId, from, to) + override def bindSigningBasketPsu(basketId: String, psuUserId: String) = underlying.bindSigningBasketPsu(basketId, psuUserId) } diff --git a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala index 0cc3d6df41..a247539262 100644 --- a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -165,6 +165,38 @@ class MappedSigningBasketProviderTest extends ServerSetup { } } + feature("the execution ledger does not depend on the request that wrote it") { + scenario("R1: the claim, the member states and the release are committed on their own, so they survive the request's transaction rolling back") { + import SigningBasketMemberState._ + import code.api.util.http4s.RequestScopeConnection + val basket = newBasket() + val member = basket.basketId.reverse // a member id of this basket's own; only the ledger row matters here + val real = code.api.util.APIUtil.vendor.HikariDatasource.ds.getConnection() + real.setAutoCommit(false) + RequestScopeConnection.currentProxy.set(RequestScopeConnection.makeProxy(real)) + try { + provider.transitionSigningBasketStatus(basket.basketId, "RCVD", "AUTHORISING").openOrThrowException("claimed") should be(true) + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, member))).openOrThrowException("recorded") should be(true) + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, member, Set(Pending), Executing, "").openOrThrowException("moved") should be(true) + provider.releaseSigningBasketMembers(basket.basketId) + } finally { + RequestScopeConnection.currentProxy.remove() + // The request dies before it commits: a crash, a timeout, a failed commit. + real.rollback() + real.close() + } + withClue("the claim that the answer was being executed: ") { + provider.getSigningBasketByBasketId(basket.basketId).openOrThrowException("basket").basket.status should equal("AUTHORISING") + } + withClue("the member that was being executed, which is what the resumption turns UNKNOWN: ") { + provider.getSigningBasketMemberExecutions(basket.basketId).map(m => m.memberId -> m.state) should equal(List(member -> Executing)) + } + withClue("the release of what the basket held: ") { + MappedSigningBasketMemberClaim.findAll(By(MappedSigningBasketMemberClaim.BasketId, basket.basketId)) should equal(Nil) + } + } + } + feature("each member of a basket has its own execution state") { import SigningBasketMemberState._ From d733920fd6ab2b560c363aa7d4da81f1c869da92 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 08:24:29 +0200 Subject: [PATCH 39/40] fix: authorise a payment only where it is held, and count wrong answers per basket A payment held by an active basket could also be authorised on its own, and each authorisation would book it once. Refuse starting and answering the payment's own authorisation while a basket holds it (409 STATUS_INVALID). Require every payment of a basket to be waiting for SCA when its authorisation is answered, as the executor does, instead of only not COMPLETED: a payment rejected, cancelled or failed in the meantime stopped the run after the other members had been booked. Count wrong answers for the basket over all its authorisations. Each new authorisation brings a new one-time password and a new allowance on its own challenge, so a TPP could keep guessing by starting authorisations; the answer that uses the basket's allowance up now rejects the basket and its payments. --- .../berlin/group/v1_3/Http4sBGv13PIS.scala | 3 + .../v1_3/Http4sBGv13SigningBaskets.scala | 13 +++- .../code/api/util/BerlinGroupError.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 1 + .../util/newstyle/SigningBasketNewStyle.scala | 12 +++- .../v1_3/SigningBasketServiceSBSApiTest.scala | 68 ++++++++++++++++++- 6 files changed, 93 insertions(+), 5 deletions(-) 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 938ca95127..2f20a40018 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 @@ -41,6 +41,7 @@ import code.api.util.CustomJsonFormats import code.api.util.APIUtil.OBPReturnType import code.api.util.{ApiTag, CallContext, Consent, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.newstyle.SigningBasketNewStyle import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection} import code.fx.fx import code.transactionrequests.TransactionRequests @@ -441,6 +442,7 @@ object Http4sBGv13PIS extends MdcLoggable { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } (_, _) <- getOwnPaymentImpl(paymentId, callContext) + _ <- SigningBasketNewStyle.requirePaymentOutsideBaskets(paymentId, callContext) (challenges, _) <- NewStyle.function.createChallengesC2( List(u.userId), ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, @@ -673,6 +675,7 @@ object Http4sBGv13PIS extends MdcLoggable { } transactionRequestId = TransactionRequestId(paymentId) (existingTransactionRequest, _) <- getOwnPaymentImpl(transactionRequestId.value, callContext) + _ <- SigningBasketNewStyle.requirePaymentOutsideBaskets(paymentId, callContext) _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUData, cc = callContext) { existingTransactionRequest.status == TransactionStatus.RCVD.code } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index dcf2539d51..b5a402231d 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -572,8 +572,11 @@ This applies in the following scenarios: paymentIds = basket.payments.getOrElse(Nil) members <- Future(paymentIds.map(id => id -> Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(id), callContext))) _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext)(members.forall(_._2.isDefined)) + // Every payment has to be waiting for SCA, the state the executor takes one in. A payment rejected, + // cancelled or failed in the meantime would be refused by the executor after the other members had + // been booked, leaving a basket that can never complete. _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { - !members.exists(_._2.exists(_._1.status == COMPLETED.toString)) + members.forall(_._2.exists(member => SigningBasketNewStyle.awaitingScaPaymentStatuses.contains(member._1.status))) } consentIds = basket.consents.getOrElse(Nil) consents <- Future(consentIds.map(id => id -> Consents.consentProvider.vend.getConsentByConsentId(id))) @@ -615,11 +618,17 @@ This applies in the following scenarios: case f: Failure => f.msg.contains("OBP-40014") case _ => false } + // Wrong answers are counted for the basket, over all its authorisations: a new authorisation brings + // a new one-time password and a new allowance on its own challenge, so counting per challenge alone + // would let a TPP keep guessing by starting new authorisations. + val answeredWrongly = _root_.code.transactionChallenge.Challenges.ChallengeProvider.vend.getChallengesByBasketId(basketId) + .map(_.map(_.attemptCounter).sum).openOr(0) + val allowance = _root_.code.api.util.APIUtil.allowedAnswerTransactionRequestChallengeAttempts val (message, code) = challengeFailure(other match { case f: Failure => f.msg case _ => InvalidChallengeAnswer }) - (if (failedForGood) rejectBasket(basketId, paymentIds, callContext) else Future.successful(())) + (if (failedForGood || answeredWrongly >= allowance) rejectBasket(basketId, paymentIds, callContext) else Future.successful(())) .flatMap(_ => Future(unboxFullOrFail(Empty: Box[ChallengeTrait], callContext, message, code))) } claimed <- Future(provider.transitionSigningBasketStatus( diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index 259b8e30aa..51da8c7aec 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -121,6 +121,7 @@ object BerlinGroupError { case "403" if message.contains("OBP-35051") => "RESOURCE_UNKNOWN" case "404" if message.contains("OBP-35052") => "RESOURCE_UNKNOWN" case "409" if message.contains("OBP-35053") => "STATUS_INVALID" + case "409" if message.contains("OBP-35059") => "STATUS_INVALID" case "403" if message.contains("OBP-35054") => "SERVICE_BLOCKED" // The PSU does not hold the accounts a consent names. The consent cannot be authorised by them, which is // the standard's "consent cannot be found with respect to the PSU". 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 793e4be700..9446b38514 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -885,6 +885,7 @@ object ErrorMessages { val SigningBasketAuthorisationDisabled = "OBP-35054: Authorising signing baskets is not enabled at this instance. " val SigningBasketMemberNotFound = "OBP-35056: A payment or consent named for the signing basket was not found. " val SigningBasketMemberStatusInvalid = "OBP-35057: A payment or consent named for the signing basket is not in a state that can be authorised, or is already in another signing basket. " + val PaymentInSigningBasket = "OBP-35059: The payment is part of a signing basket and is authorised through the basket. " val SigningBasketMemberMixInvalid = "OBP-35058: The payments and consents named for the signing basket cannot be authorised together. " val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. " val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. " diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 0262250e38..ab1525bb7f 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -33,7 +33,7 @@ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.BerlinGroupPaymentAccess import code.api.util.Consent import code.consent.{ConsentStatus, Consents} -import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketMemberMixInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} +import code.api.util.ErrorMessages.{PaymentInSigningBasket, ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketMemberMixInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} import code.bankconnectors.Connector import code.consumer.Consumers import code.signingbaskets.SigningBasketX @@ -208,6 +208,16 @@ object SigningBasketNewStyle extends MdcLoggable { booleanToFuture(failMsg = SigningBasketNotFound, failCode = 403, cc = callContext)(named.forall(_ == psuUserId)).map(_ => ()) } + /** + * A payment that an active basket holds is authorised through the basket. Authorising it on its own as well + * would book it twice, once by each. Only the check is shared with the basket: the two authorisations are + * answered with different one-time passwords, in separate requests. + */ + def requirePaymentOutsideBaskets(paymentId: String, callContext: Option[CallContext]): Future[Unit] = + booleanToFuture(failMsg = PaymentInSigningBasket, failCode = 409, cc = callContext) { + !SigningBasketX.signingBasketProvider.vend.memberHeldByBasket(s"payment:$paymentId") + }.map(_ => ()) + // A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked, // rejected or cancelled, or is being authorised some other way. The executor accepts the same set. val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED") diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 5babbfbbba..dccf9f1800 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -991,10 +991,12 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { val started = startedBasket() val wrong = """{"scaAuthenticationData":"definitely-wrong"}""" val allowed = code.api.util.APIUtil.allowedAnswerTransactionRequestChallengeAttempts - val codes = (1 to allowed + 1).map(_ => answerAuthorisation(started.basketId, started.authorisationId, body = wrong).code) + val codes = (1 to allowed).map(_ => answerAuthorisation(started.basketId, started.authorisationId, body = wrong).code) withClue(s"codes $codes: ") { codes.foreach(_ should equal(401)) - storedBasketStatus(started.basketId) should equal(Some("RJCT")) + withClue("the last wrong answer the allowance covers closes the basket: ") { + storedBasketStatus(started.basketId) should equal(Some("RJCT")) + } started.paymentIds.foreach(storedPaymentStatus(_) should equal("REJECTED")) } withClue("the right answer no longer authorises anything: ") { @@ -1218,6 +1220,68 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } feature("BG v1.3 signing baskets - what a review of the execution found") { + scenario("R3: a payment that is no longer waiting for SCA stops the answer before anything is booked", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + List("REJECTED", "CANCELLED", "FAILED").foreach { status => + val first = lodgePayment() + val second = lodgePayment() + val basketId = createBasket(List(first, second)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, second)).openOrThrowException("payment") + .mStatus(status).saveMe() + val before = balanceOf(ibanFrom) + withClue(s"a payment that is $status: ") { + answerAuthorisation(basketId, authorisationId).code should equal(409) + balanceOf(ibanFrom) should equal(before) + storedPaymentStatus(first) should equal(awaitingSca) + storedBasketStatus(basketId) should equal(Some("RCVD")) + memberResults(basketId) should equal(Nil) + } + } + } + + scenario("R2: a payment that is in a basket cannot also be authorised on its own", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val payment = lodgePayment() + val singleAuthorisations = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / payment / "authorisations" + val started = makePostRequest(singleAuthorisations.POST <@ (user1), "{}") + started.code should equal(201) + val singleAuthorisationId = (started.body \ "authorisationId").extract[String] + createBasket(List(payment)) + + val before = balanceOf(ibanFrom) + expectRefusal(makePutRequest((singleAuthorisations / singleAuthorisationId).PUT <@ (user1), """{"scaAuthenticationData":"123"}"""), + 409, "STATUS_INVALID", "answering the payment's own authorisation while a basket holds it") + withClue("nothing was booked, and the payment is still the basket's to authorise: ") { + balanceOf(ibanFrom) should equal(before) + storedPaymentStatus(payment) should equal(awaitingSca) + } + expectRefusal(makePostRequest(singleAuthorisations.POST <@ (user1), "{}"), + 409, "STATUS_INVALID", "starting a second authorisation for a payment a basket holds") + } + + scenario("R5: wrong answers are counted for the basket, not for each authorisation, so new authorisations are no new guesses", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val allowed = code.api.util.APIUtil.allowedAnswerTransactionRequestChallengeAttempts + val wrong = """{"scaAuthenticationData":"definitely-wrong"}""" + val started = startedBasket() + (1 until allowed).foreach(_ => answerAuthorisation(started.basketId, started.authorisationId, body = wrong).code should equal(401)) + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + + val second = startAuthorisation(started.basketId) + second.code should equal(201) + val secondId = (second.body \ "authorisationId").extract[String] + withClue("the wrong answer that uses up the basket's allowance, on a new authorisation: ") { + answerAuthorisation(started.basketId, secondId, body = wrong).code should equal(401) + storedBasketStatus(started.basketId) should equal(Some("RJCT")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal("REJECTED")) + } + expectRefusal(startAuthorisation(started.basketId), 409, "STATUS_INVALID", "a new authorisation on a rejected basket") + expectRefusal(answerAuthorisation(started.basketId, secondId), 409, "STATUS_INVALID", "the right answer on a rejected basket") + } + scenario("R6: a basket whose execution throws does not stop the resumption reaching the others, and goes to the back of the queue", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { val ibanFrom = ibanAccounts.head val provider = code.signingbaskets.MappedSigningBasketProvider From 60f9c3db523d24380ed21836c0d2823ed3426f45 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 09:01:13 +0200 Subject: [PATCH 40/40] test: cover the answers and failures of a connector other than the mapped one A connector that does not accept a one-time password can return the challenge itself with a failed or unfinished status, and a booking can fail on a connector that may have booked before it failed. Neither could be produced by the mapped connector the suite runs on, so both rules were only read, not run. Replace the connector with one that answers named methods itself and passes the rest to the connector in use. A challenge reported as failed is a refusal and rejects the basket with its payments; one that is not finalised authorises nothing and leaves the basket as it was; a booking failure is recorded UNKNOWN and never retried, where the same failure on the mapped connector is FAILED and claimed again. --- .../v1_3/SigningBasketServiceSBSApiTest.scala | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index dccf9f1800..77461440f7 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -1334,6 +1334,128 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { expectRefusal(makeDeleteRequest(basketUrl(basketId).DELETE <@ (user1)), 409, "STATUS_INVALID", "deleting a basket that ended") } } + + /** + * Runs `body` with the connector replaced by one that answers the named methods itself and hands every other + * call to the connector that was in use. Not the mapped connector and not the star connector, which is what + * a deployment with a single configured remote connector looks like to the code that asks which one it has. + */ + private def withConnector[A](overrides: PartialFunction[String, Array[AnyRef] => AnyRef])(body: => A): A = { + val original = code.bankconnectors.Connector.connector.vend + val handler = new java.lang.reflect.InvocationHandler { + override def invoke(proxy: AnyRef, method: java.lang.reflect.Method, args: Array[AnyRef]): AnyRef = { + val arguments = Option(args).getOrElse(Array.empty[AnyRef]) + overrides.lift(method.getName) match { + case Some(answer) => answer(arguments) + case None => + try method.invoke(original, arguments: _*) + catch { case e: java.lang.reflect.InvocationTargetException => throw e.getCause } + } + } + } + val replacement = java.lang.reflect.Proxy + .newProxyInstance(classOf[code.bankconnectors.Connector].getClassLoader, Array(classOf[code.bankconnectors.Connector]), handler) + .asInstanceOf[code.bankconnectors.Connector] + code.bankconnectors.Connector.connector.default.set(replacement) + try body finally code.bankconnectors.Connector.connector.default.set(original) + } + + /** The challenge the connector was asked about, reporting the given SCA status instead of its own. */ + private def challengeReporting(challengeId: String, status: StrongCustomerAuthenticationStatus.SCAStatus): com.openbankproject.commons.model.ChallengeTrait = { + val real = Challenges.ChallengeProvider.vend.getChallenge(challengeId).openOrThrowException("the challenge must exist") + java.lang.reflect.Proxy.newProxyInstance( + classOf[com.openbankproject.commons.model.ChallengeTrait].getClassLoader, + Array(classOf[com.openbankproject.commons.model.ChallengeTrait]), + new java.lang.reflect.InvocationHandler { + override def invoke(proxy: AnyRef, method: java.lang.reflect.Method, args: Array[AnyRef]): AnyRef = + if (method.getName == "scaStatus") Some(status) + else try method.invoke(real, Option(args).getOrElse(Array.empty[AnyRef]): _*) + catch { case e: java.lang.reflect.InvocationTargetException => throw e.getCause } + }).asInstanceOf[com.openbankproject.commons.model.ChallengeTrait] + } + + feature("BG v1.3 signing baskets - what a connector other than the mapped one can answer") { + // The connector answers a one-time password it did not accept by handing back the challenge itself, with + // a status that says so, instead of failing. + def connectorAnswering(status: StrongCustomerAuthenticationStatus.SCAStatus): PartialFunction[String, Array[AnyRef] => AnyRef] = { + case "validateChallengeAnswerC5" => arguments => + Future.successful((net.liftweb.common.Full(challengeReporting(arguments(3).asInstanceOf[String], status)), arguments(6))) + } + + scenario("X1: a challenge the connector hands back as failed is a refusal, and the basket is rejected with its payments", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val started = startedBasket(paymentCount = 2) + val before = balanceOf(ibanFrom) + withConnector(connectorAnswering(StrongCustomerAuthenticationStatus.failed)) { + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 401, "PSU_CREDENTIALS_INVALID", "an answer the connector reports as failed") + } + withClue("nothing was booked, and nothing can be answered again: ") { + balanceOf(ibanFrom) should equal(before) + storedBasketStatus(started.basketId) should equal(Some("RJCT")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal("REJECTED")) + memberResults(started.basketId) should equal(Nil) + } + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 409, "STATUS_INVALID", "the right answer afterwards") + } + + scenario("X2: a challenge the connector hands back without finalising it authorises nothing, and does not reject the basket", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val started = startedBasket() + val before = balanceOf(ibanFrom) + withConnector(connectorAnswering(StrongCustomerAuthenticationStatus.received)) { + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 401, "PSU_CREDENTIALS_INVALID", "an answer the connector has not finalised") + } + balanceOf(ibanFrom) should equal(before) + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + withClue("and the PSU can still answer it properly: ") { + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + balanceOf(ibanFrom) should equal(before - 2001) + } + } + + scenario("X3: a booking that fails on a connector other than the mapped one may have been made, so it is left UNKNOWN and never retried", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val payment = lodgePayment() + val basketId = createBasket(List(payment)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + val before = balanceOf(ibanFrom) + val bookings = new java.util.concurrent.atomic.AtomicInteger(0) + val failing: PartialFunction[String, Array[AnyRef] => AnyRef] = { + case "createTransactionAfterChallengeV210" => arguments => + bookings.incrementAndGet() + Future.failed(new RuntimeException("the backend timed out, and may have booked")) + } + withConnector(failing) { + answerAuthorisation(basketId, authorisationId).code should equal(200) + (1 to 3).foreach { _ => + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + } + } + withClue("one attempt, however often the basket is executed again: ") { bookings.get() should equal(1) } + memberResults(basketId) should equal(List((payment, "UNKNOWN", 1))) + storedBasketStatusRaw(basketId) should equal("EXECUTION_INCOMPLETE") + withClue("not terminal, and still held: it is waiting for a person to say whether the money moved: ") { + code.signingbaskets.MappedSigningBasketMemberClaim.find(By(code.signingbaskets.MappedSigningBasketMemberClaim.MemberKey, s"payment:$payment")).isDefined should be(true) + } + balanceOf(ibanFrom) should equal(before) + } + + scenario("X4: the same failure on the mapped connector is a payment that was not booked, and is tried again", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val payment = lodgePaymentThatCannotBeBooked() + val basketId = createBasket(List(payment)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId).code should equal(200) + (1 to 3).foreach { _ => + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + } + withClue("claimed again up to the attempts allowed, and failed each time, not left UNKNOWN: ") { + memberResults(basketId) should equal(List((payment, "FAILED", 3))) + } + } + } } /** A provider that behaves as the real one, except that reading one particular basket throws, as a database failure would. */