diff --git a/docs/signing_basket_operations.md b/docs/signing_basket_operations.md new file mode 100644 index 0000000000..10ddd5ca1d --- /dev/null +++ b/docs/signing_basket_operations.md @@ -0,0 +1,137 @@ +# 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`. 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`. + +| 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. 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 +-- 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, 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 + 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 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 + 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. diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 5dcd849c40..1d792aa0a8 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1672,9 +1672,27 @@ 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 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 + +# 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 a68570e7c7..6eaef2f8dc 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, MappedSigningBasketMemberExecution, MappedSigningBasketPayment} import code.socialmedia.MappedSocialMedia import code.standingorders.StandingOrder import code.taxresidence.MappedTaxResidence @@ -643,6 +643,7 @@ class Boot extends MdcLoggable { } ConsentScheduler.startAll() TransactionScheduler.startAll() + SigningBasketScheduler.startAll() code.metrics.MetricsProps.enableMetricsScheduler match { @@ -1004,6 +1005,8 @@ object ToSchemify extends MdcLoggable { MappedSigningBasket, 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 6802d93566..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 @@ -47,5 +47,29 @@ 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" + + /** + * 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" + + /** + * 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 || storedStatus == EXECUTION_FAILED_INTERNAL) RCVD.toString else storedStatus } } 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..16a73fd8d3 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala @@ -0,0 +1,77 @@ +/** +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, 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. + * + * 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) { + 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)) + } yield valid +} 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/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala index a961d1c0d9..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 @@ -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] { @@ -719,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/berlin/group/v1_3/Http4sBGv13PIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala index 0f9e0e5a83..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 @@ -101,44 +102,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 / @@ -474,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, @@ -706,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 aa8ca2788e..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 @@ -32,22 +32,24 @@ 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, passesPsd2Aisp, passesPsd2Pisp, unboxFullOrFail} 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, 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.signingbaskets.SigningBasketX +import code.consent.{ConsentStatus, Consents} +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 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 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 import org.http4s._ @@ -73,30 +75,73 @@ 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" => - EndpointHelpers.executeFutureCreated(req) { + 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] } + // 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) } + // 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, + psuUserId ) - }.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) } + } { 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}") } } @@ -134,10 +179,10 @@ 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), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketMemberMixInvalid, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(createSigningBasket) ) @@ -148,10 +193,27 @@ The resource identifications of these transactions are contained in the payload EndpointHelpers.executeDelete(req) { cc => val callContext = Some(cc) for { - _ <- passesPsd2Pisp(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)) + // The members are free to join another basket. + _ <- Future(SigningBasketX.signingBasketProvider.vend.releaseSigningBasketMembers(basketid)) + } yield true } yield () } } @@ -164,14 +226,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) ) @@ -182,10 +246,7 @@ Nevertheless, single transactions might be cancelled on an individual basis on t EndpointHelpers.executeAndRespond(req) { cc => 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) } @@ -202,11 +263,11 @@ 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" : "" }""")), - List(AuthenticatedUserIsRequired, UnknownError), + List(AuthenticatedUserIsRequired, SigningBasketNotFound, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(getSigningBasket) ) @@ -217,7 +278,7 @@ 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 { JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) @@ -240,7 +301,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) ) @@ -251,14 +312,10 @@ This function returns an array of hyperlinks to all generated authorisation sub- EndpointHelpers.executeAndRespond(req) { cc => 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("")) } } } @@ -276,7 +333,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) ) @@ -287,10 +344,7 @@ 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 <- Future { - SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) - }.map(connectorEmptyResponse(_, callContext)) + (basket, _) <- SigningBasketNewStyle.getOwnBasket(basketid, CreatorOnly, callContext) } yield { getSigningBasketStatusResponseJson(basket) } @@ -310,11 +364,56 @@ 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) ) + // ── 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" => @@ -322,9 +421,18 @@ 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, + 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( - List(cc.user.map(_.userId).openOr("")), + List(psuUserId), ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, getSuggestedDefaultScaMethod(), @@ -385,46 +493,107 @@ 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), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketNotFound, SigningBasketStatusInvalid, SigningBasketAuthorisationVariantNotSupported, BerlinGroupPsuNotIdentified, UnknownError), apiTagSigningBaskets :: Nil, 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) + + /** + * 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: + // 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) + (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(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)) + // 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.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))) + _ <- 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) + // 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) => + previous.flatMap(_ => Consent.assertBerlinGroupConsentAccountsHeld(psu, consent, callContext).map(_ => ())) + } (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, @@ -433,53 +602,42 @@ 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") + // 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 } - case _ => - Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) + // 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 || answeredWrongly >= allowance) 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)) + _ <- 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. + allDone <- SigningBasketExecution.execute(basketId, callContext) } yield { - JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge, executionIncomplete = !allDone) } } } @@ -535,13 +693,14 @@ 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), + List(AuthenticatedUserIsRequired, InvalidJsonFormat, SigningBasketNotFound, SigningBasketAuthorisationNotFound, SigningBasketAuthorisationVariantNotSupported, SigningBasketAuthorisationDisabled, SigningBasketStatusInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, InvalidChallengeAnswer, UnknownError), apiTagSigningBaskets :: Nil, http4sPartialFunction = Some(updateSigningBasketPsuData) ) @@ -553,6 +712,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 1d4eaa2a10..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 @@ -70,6 +70,16 @@ 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) + // 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]], @@ -875,19 +885,35 @@ 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}") + ) + ) + } + + /** 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, executionIncomplete: Boolean = false) = { + ScaStatusResponse( + scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""), + // 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}"))))) ) } def createSigningBasketResponseJson(basket: SigningBasketTrait): SigningBasketResponseJson = { SigningBasketResponseJson( basketId = basket.basketId, - transactionStatus = basket.status.toLowerCase(), + 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"), @@ -898,15 +924,22 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status.toLowerCase(), + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), payments = basket.payments, consents = basket.consents, ) } + 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 = basket.basket.status.toLowerCase(), + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), payments = None, consents = None, ) 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..a167be1951 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -0,0 +1,330 @@ +/** +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.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 +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 + +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) + + // 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 + * 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)) + } + // 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) + } + } + + /** + * 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) + + /** + * 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] = + 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) || + 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 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. + provider.touchSigningBasket(basketId) + false + } + } + + /** + * 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) + 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 (!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 + // 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) + } + + /** 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) + + /** + * 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) + + 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. + 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) + } + } + } + + 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(_) => 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) => + 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)}") + } + } + } + + /** + * 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) + } + + /** + * 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 { + 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/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/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index cadb2c65cc..51da8c7aec 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,25 @@ 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" + // 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 "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". + 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" + // 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/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 92e057c7d4..d20f3c5ae8 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,43 @@ 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) + } + } + + /** + * 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], 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..9446b38514 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,17 @@ 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 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 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. " 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/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/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 046e8c6e9b..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 @@ -27,39 +27,289 @@ 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.util.ErrorMessages.{InvalidConnectorResponse, RegulatedEntityNotDeleted} +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.{PaymentInSigningBasket, ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketMemberMixInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} import code.bankconnectors.Connector +import code.consumer.Consumers import code.signingbaskets.SigningBasketX -import com.openbankproject.commons.model.TransactionRequestId -import net.liftweb.common.{Box, Empty} +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, TransactionRequest, TransactionRequestId} +import net.liftweb.common.{Box, Empty, Full} import scala.concurrent.Future -object SigningBasketNewStyle { +object SigningBasketNewStyle extends MdcLoggable { import com.openbankproject.commons.ExecutionContext.Implicits.global - 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") + /** + * 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) + } 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] = + 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 } - } map { - (_, callContext) - } map { - x => (unboxFullOrFail(x._1, callContext, RegulatedEntityNotDeleted, 400), x._2) + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + awaitingScaPaymentStatuses.contains(payment.status) + } + } yield paymentPsu(payment, cc.consumer.map(_.key.get)) + + /** + * 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, tppConsumerKey: Option[String]): Boolean = + Users.users.vend.getUserByUserId(userId).toOption + .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 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") + + /** + * 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. + * + * 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(_ => "") + } + _ <- 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 + + /** + * 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) + } } 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 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/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index d8bd342f55..a23e6fb71b 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -30,79 +30,302 @@ 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() } - 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), OrderBy(MappedSigningBasketPayment.id, Ascending)).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), OrderBy(MappedSigningBasketConsent.id, Ascending)).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 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) - } - basket.map( i => SigningBasketContent(basket = i, payments = payments, consents = 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 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. 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") + 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) + // 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() + } + 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() + } + 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)) + basket.delete_! } - consentIds.getOrElse(Nil).map { consentId => - MappedSigningBasketConsent.create.BasketId(entity.basketId).ConsentId(consentId).saveMe() + } + 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 + } + } + + override def createSigningBasketMemberExecutions(basketId: String, members: List[(String, String)]): Box[Boolean] = + tryo { + 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) => () + } } - entity + true + } + + 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))) } } - override def deleteSigningBasket(id: String): Box[Boolean] = { - MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map { - _.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save + /** + * 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. + 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, + 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 "" + 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} = ? " + + s"AND ${m.State._dbColumnNameLC} IN (${fromList.map(_ => "?").mkString(", ")})", + List[Any](to, detail.take(2000), now, 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) + 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} < ?", + List[Any](SigningBasketMemberState.Unknown, "The executor stopped before recording an outcome", now, 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) + } + + // 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 { + 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 { + ledgerUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} " + + s"SET ${MappedSigningBasket.Status._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = ? " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?", + 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) + ledgerUpdate( + 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} = ? " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? " + + s"AND (${MappedSigningBasket.PsuUserId._dbColumnNameLC} IS NULL OR ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = '')", + 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)) + } + } -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) } @@ -142,3 +365,34 @@ 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 +} + +/** 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 8400fc113a..97d1800aef 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -38,17 +38,110 @@ 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] def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] - def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] + /** + * 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]], + consumerId: String, + psuUserId: Option[String] ): Box[SigningBasketTrait] - def deleteSigningBasket(id: String): Box[Boolean] + /** 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] + + /** + * 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] + + /** 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. + */ + 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. + * 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/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, 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..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 @@ -27,23 +27,37 @@ 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 com.openbankproject.commons.model.User +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)) @@ -53,28 +67,149 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def 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 ───────────────────────────── + + // 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") - // 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 + 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", 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 => - Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, resourceUser1) + Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, initiator) ) val initiatePaymentJson = s"""{ | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, - | "instructedAmount": { "currency": "EUR", "amount": "10" }, - | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "$amount" }, + | "creditorAccount": { "iban": "${creditorIban.getOrElse(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) - val payment = response.body.extract[InitiatePaymentResponseJson] - payment.transactionStatus should be(TransactionStatus.ACCP.code) - payment.paymentId + 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. */ + 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.body}: ") { 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 +268,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 +308,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 +400,1080 @@ 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 + // ───────────────────────── the happy path, with real payments ───────────────────────── - 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) - - 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") + 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") + } + + 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) + } + + 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: authorising a basket of a consent makes it valid and binds it to the PSU", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + // 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] + + 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")) + } + + 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 ───────────────────────── + + 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) + } + } + } + + // ───────────────────────── 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(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] + 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(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) + } + + 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(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") + storedChallengeCount(basketId) should equal(challengesBefore) + } + } + + // ───────────────────────── 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) } } + 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") { + 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 is the standard's incorrect-OTP refusal and changes nothing (IG §14.11 PSU_CREDENTIALS_INVALID, L11597)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + 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(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] + 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) + } + } + + // ───────────────────────── execution: every member is booked, and what happened to each is recorded ───────────────────────── + + 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(paymentCount = 2) + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + 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: 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 + 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 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("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).map(_ => answerAuthorisation(started.basketId, started.authorisationId, body = wrong).code) + withClue(s"codes $codes: ") { + codes.foreach(_ should equal(401)) + 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: ") { + 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 + 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 on the mapped connector is otherwise known not to have been booked", 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() + // 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) + 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") + } + } + + 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)) + } + } + + 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")) + } + } + + 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") { + 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") + } + } + } + } + } + + 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 + 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") + } + } + + /** + * 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))) + } + } + } +} -} \ No newline at end of file +/** 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/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..878266ad0a --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketSignedRequestTest.scala @@ -0,0 +1,146 @@ +/** +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") + } + } +} 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 + } + } + } + } +} 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..a247539262 --- /dev/null +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -0,0 +1,286 @@ +/** +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 + + // 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 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(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)) + } + + 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"))) + } + } + + 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))) + 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) + } + } + } + } + + 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._ + + 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("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() + 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 858f87c003..b80f0f4549 100644 --- a/obp-api/src/test/scala/code/util/MappedClassNameTest.scala +++ b/obp-api/src/test/scala/code/util/MappedClassNameTest.scala @@ -144,6 +144,8 @@ class MappedClassNameTest extends FeatureSpec { "code.signingbaskets.MappedSigningBasketConsent", "code.signingbaskets.MappedSigningBasket", "code.signingbaskets.MappedSigningBasketPayment", + "code.signingbaskets.MappedSigningBasketMemberClaim", + "code.signingbaskets.MappedSigningBasketMemberExecution", "code.CustomerDependants.MappedCustomerDependant", ) 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, diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 49573fcf40..0079c1ffef 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": "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" + }, { "version": "v4_0_0", "endpoint": "deleteExplicitCounterparty", @@ -771,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",