diff --git a/docs/signing_basket_operations.md b/docs/signing_basket_operations.md new file mode 100644 index 0000000000..467b9192fc --- /dev/null +++ b/docs/signing_basket_operations.md @@ -0,0 +1,111 @@ +# Signing baskets: operating notes + +For whoever runs an OBP-API instance that offers the Berlin Group signing basket. The behaviour of the API +itself is in the ResourceDocs; this covers what an operator does and sees. + +## Turning authorisation on + +`signing_basket_authorisation_enabled` is `false` by default. While it is, creating, reading, deleting a +basket and starting an authorisation work, and answering the authorisation (the PUT) answers 403 +`SERVICE_BLOCKED`. Roll it out in stages: first the ownership guard (this change with the property off), +then the property, with the recovery rehearsal below in between. + +Related properties: + +| Property | Default | Meaning | +|---|---|---| +| `signing_basket_member_max_attempts` | 3 | Times a payment that failed to book is claimed again. Mapped connector only. | +| `signing_basket_resume_interval_in_seconds` | 593 | How often stopped executions are resumed. 0 switches it off. | +| `signing_basket_execution_lease_in_seconds` | 300 | How long a basket or member may sit without moving before it counts as stopped. | + +## What the stored status means + +A basket reports `RCVD`, `PATC`, `ACTC`, `CANC` or `RJCT`. Two more are stored and reported as `RCVD`: + +* `AUTHORISING`: the authorisation was answered correctly and the basket was claimed; its members are being + executed. +* `EXECUTION_INCOMPLETE`: execution stopped with a member that is not `DONE`. + +`ACTC` means every member is `DONE`. Each member has its own state in `SigningBasketMemberExecution`, and the +creating TPP reads it from `GET /signing-baskets/{basketId}/execution`. + +| Member state | Meaning | +|---|---| +| `PENDING` | Not started. | +| `EXECUTING` | Claimed by an executor. Past the lease it becomes `UNKNOWN`. | +| `DONE` | Payment booked, or consent activated. | +| `FAILED` | Refused before it took effect. Claimed again automatically, on the mapped connector, up to the attempts allowed. | +| `UNKNOWN` | The executor stopped without recording an outcome, or a connector other than the mapped one failed after it may have booked. | + +Several payments in one basket are not one transaction. A failure leaves the earlier payments booked. + +## Looking at baskets that did not complete + +```sql +-- Baskets that did not reach ACTC after their authorisation was answered +SELECT basketid, status, consumerid, psuuserid, updatedat +FROM signingbasket +WHERE status IN ('AUTHORISING', 'EXECUTION_INCOMPLETE') +ORDER BY updatedat; + +-- What happened to each member of one basket +SELECT membertype, memberid, position, state, detail, attempts, updatedat +FROM SigningBasketMemberExecution +WHERE basketid = '' +ORDER BY position; +``` + +## Members left UNKNOWN + +The resumption reconciles an `UNKNOWN` payment by its transaction id: if the payment carries one it was +booked, and the member becomes `DONE`. Without one, nothing proves whether it was booked, so it is left for you. + +1. Find the payment's debit in the ledger (the debtor account, the amount, the time of the execution). +2. If it was booked, set the payment's transaction id and mark the member `DONE`; the next resumption completes + the basket. If it was not, mark the member `FAILED`; it is then claimed again, up to the attempts allowed. + +On a connector other than the mapped one, a failure is always recorded as `UNKNOWN`, because that connector +may have booked before it failed, and automatic retry is off. + +## Baskets created before ownership was recorded + +Baskets created before this change have no creating TPP and no PSU. They cannot be attributed safely, so every +operation answers them as unknown (403 `RESOURCE_UNKNOWN`), they are never authorised, and their rows are kept. +Nothing assigns one to whoever asks first, and no property re-opens them. + +To find them: + +```sql +SELECT basketid, status, createdat +FROM signingbasket +WHERE consumerid IS NULL OR consumerid = ''; +``` + +If a particular one has to be revived, assign it explicitly, after establishing who created it: + +```sql +UPDATE signingbasket +SET consumerid = '' +WHERE basketid = '' AND (consumerid IS NULL OR consumerid = ''); +``` + +Its payments and consents are not held by any claim. If the basket is to be used, add the claims: + +```sql +INSERT INTO SigningBasketMemberClaim (memberkey, basketid, createdat, updatedat) +VALUES ('payment:', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); +``` + +(one row per payment, `consent:` for consents). Without them another basket could take the same +payment. A basket that is only to be closed needs none of this; set its status to `CANC`. + +## Things that can still surprise + +* A payment waiting in a basket is still a payment waiting for SCA: if + `berlin_group_outdated_transactions_interval_in_seconds` is set, the outdated-payment task rejects it after + `berlin_group_outdated_transactions_time_in_seconds`, and the basket's member then fails as not waiting for + authorisation. +* A consent in a basket is `received` until activated, and the consent scheduler rejects a Berlin Group consent + that stays `received` for `berlin_group_outdated_consents_time_in_seconds`. +* The recurrence of a periodic payment is not stored, so a periodic payment cannot be recognised and refused when + it is put in a basket; it is treated as a single payment. 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..ee929d7bcc 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala @@ -47,5 +47,22 @@ 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" + + def external(storedStatus: String): String = + if (storedStatus == AUTHORISING_INTERNAL || storedStatus == EXECUTION_INCOMPLETE_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..53c90d647f --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/BerlinGroupConsentActivation.scala @@ -0,0 +1,62 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.berlin.group.v1_3 + +import code.api.util.APIUtil.unboxFullOrFail +import code.api.util.ErrorMessages.{ConsentAccountAccessCannotBeGranted, ConsentUpdateStatusError} +import code.api.util.{CallContext, Consent} +import code.consent.{ConsentStatus, ConsentTrait, Consents} +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.User + +import scala.concurrent.Future + +/** + * Makes a Berlin Group consent valid once the PSU's SCA for it has succeeded: grants the access an + * "allAccounts" consent leaves open, marks the consent valid, and binds it to the PSU. + * + * The consent authorisation (PUT /consents/{id}/authorisations/{id}) performs the same steps inline, + * interleaved with checking the answer. A signing basket checks the answer once for all its members and + * then activates each consent here. The order is the one the consent route documents: grant before the + * status changes, so a consent never becomes valid without the access it names; bind last. + * + * Idempotent, so that a basket resumed after a stop can run it again: a consent already valid and bound + * to this PSU is returned as it is. + */ +object BerlinGroupConsentActivation { + + def activate(consent: ConsentTrait, psu: User, callContext: Option[CallContext]): Future[ConsentTrait] = + if (consent.status == ConsentStatus.valid.toString && consent.userId == psu.userId) Future.successful(consent) + else for { + _ <- Consent.grantBerlinGroupAvailableAccountsAccess(psu, consent) + .map(unboxFullOrFail(_, callContext, ConsentAccountAccessCannotBeGranted)) + valid <- Future(Consents.consentProvider.vend.updateConsentStatus(consent.consentId, ConsentStatus.valid)) + .map(unboxFullOrFail(_, callContext, ConsentUpdateStatusError)) + _ <- Consent.bindBerlinGroupConsentToPsu(consent.consentId, psu, callContext) + } yield valid +} diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/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..938ca95127 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala @@ -101,44 +101,11 @@ object Http4sBGv13PIS extends MdcLoggable { }.isDefined /** - * Fetch a payment the caller is entitled to address. - * - * Berlin Group names a payment by its id alone — there is no account in the path — so nothing in - * the route ties the payment to whoever is calling. Fetching one must therefore also establish - * that the caller is the party that lodged it; otherwise any authenticated TPP holding a paymentId - * could read another TPP's payment, list or start authorisations on it, or cancel it. Under - * NextGenPSD2 a payment initiation resource belongs to the TPP that created it, and only that TPP - * addresses it afterwards. - * - * Two things have to line up, because Berlin Group binds a payment to the TPP and the ASPSP - * separately knows which PSU it is for. - * - * - The TPP. The consumer that lodged the payment is recorded on it, and a caller presenting a - * different one is refused even when it is acting for the same PSU: one TPP's mandate over a - * payment is not another's. Payments lodged before the consumer was recorded carry none, and - * fall back to the person check alone rather than becoming unaddressable. - * - The person. A payment records the principal that lodged it and, when it was lodged under a - * consent, the PSU it was lodged for; a caller presents the same two. Any overlap is enough, so - * a payment lodged on a client-credentials token can still be authorised under the PSU's token - * and the other way round. A payment carrying neither identity belongs to nobody. + * Fetch a payment the caller is entitled to address: the TPP that lodged it, acting as a principal + * that is party to it. The rule lives in BerlinGroupPaymentAccess, which the signing basket shares. */ private def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] = - for { - (transactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) - initiators = Set(transactionRequest.user_id, transactionRequest.on_behalf_of_user_id).flatten.filter(_.nonEmpty) - callers = callContext.toSet[CallContext].flatMap(cc => cc.user.toOption.map(_.userId) ++ Consent.actingPsu(cc).map(_.userId)) - callingConsumer = callContext.flatMap(_.consumer.map(_.consumerId.get)) - // Read straight off the stored row rather than through the TransactionRequest model: which - // TPP lodged a payment is this guard's business, not something every REST connector needs on - // the wire, and that model's shape is a frozen contract. - lodgedByConsumer = TransactionRequests.transactionRequestProvider.vend - .getMappedTransactionRequest(TransactionRequestId(paymentId)) - .toOption.flatMap(tr => Consent.present(tr.mConsumerId.get)) - sameTpp = lodgedByConsumer.forall(lodgedBy => callingConsumer.contains(lodgedBy)) - _ <- Helper.booleanToFuture(s"$PaymentNotInitiatedByCaller Payment id: $paymentId.", 403, callContext) { - sameTpp && initiators.exists(callers) - } - } yield (transactionRequest, callContext) + BerlinGroupPaymentAccess.getOwnPayment(paymentId, callContext) /** * Shared business logic for all three initiate-payment variants (payments / periodic-payments / 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..2d76fa32ae 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -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,82 @@ 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) + // ── 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)) + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + !members.exists(_._2.exists(_._1.status == COMPLETED.toString)) + } + consentIds = basket.consents.getOrElse(Nil) + consents <- Future(consentIds.map(id => id -> Consents.consentProvider.vend.getConsentByConsentId(id))) + _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext)(consents.forall(_._2.isDefined)) + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + consents.forall(_._2.exists(_.status == ConsentStatus.received.toString)) + } + // The answer is the PSU's, relayed by the TPP under Embedded, so it is checked against the + // challenge's own PSU rather than the principal on the token. + (psu, _) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) + // The PSU has to hold the accounts each consent names, as when they authorise a consent on its own. + // Before the answer is checked and before anything changes: activation is not one transaction. + _ <- consents.flatMap(_._2.toList).foldLeft(Future.successful(())) { (previous, consent) => + previous.flatMap(_ => Consent.assertBerlinGroupConsentAccountsHeld(psu, consent, callContext).map(_ => ())) + } (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, @@ -433,53 +577,28 @@ This applies in the following scenarios: authorisationId, updateBasketPsuDataJson.scaAuthenticationData, SuppliedAnswerType.PLAIN_TEXT_VALUE, - callContext + callContext.map(_.copy(user = Full(psu))) ) - (challenge, updatedCC) <- NewStyle.function.getChallenge(authorisationId, callContext) - _ <- challenge.scaStatus match { - case Some(status) if status.toString == StrongCustomerAuthenticationStatus.finalised.toString => - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - val existAll = - basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).isDefined))) - val alreadyCompleted: List[String] = - basket.flatMap(_.payments).getOrElse(Nil).filter { i => - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC) - .exists(_._1.status == COMPLETED.toString) - } - if (alreadyCompleted.nonEmpty) { - unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${alreadyCompleted.mkString(",")}] are already completed") - } else if (existAll.getOrElse(false)) { - basket.map { i => - i.payments.map(_.map { i => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), COMPLETED.toString, updatedCC) - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).map { t => - Connector.connector.vend.makePaymentV400(t._1, None, updatedCC) - } - }) - } - SigningBasketX.signingBasketProvider.vend.saveSigningBasketStatus(basketId, ConstantsBG.SigningBasketsStatus.ACTC.toString) - unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") - } else { - val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") - unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") - } - } - case Some(status) if status.toString == StrongCustomerAuthenticationStatus.failed.toString => - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - basket.map { i => - i.payments.map(_.map { i => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), REJECTED.toString, updatedCC) - }) - } - unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") - } - case _ => - Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) + challenge <- Future { + boxedChallenge match { + case Full(answered) => answered + case failure => + val (message, code) = challengeFailure(failure match { + case f: Failure => f.msg + case _ => InvalidConnectorResponse + }) + unboxFullOrFail(Empty: Box[ChallengeTrait], callContext, message, code) + } } + claimed <- Future(provider.transitionSigningBasketStatus( + basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(claimed.openOr(false)) + // Each member is recorded, then booked in order. The basket becomes ACTC only if every one is. + _ <- Future(provider.createSigningBasketMemberExecutions(basketId, + paymentIds.map(SigningBasketMemberState.PaymentType -> _) ::: consentIds.map(SigningBasketMemberState.ConsentType -> _))) + allDone <- SigningBasketExecution.execute(basketId, callContext) } yield { - JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge, executionIncomplete = !allDone) } } } @@ -535,13 +654,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 +673,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..af2b40c3a4 --- /dev/null +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/SigningBasketExecution.scala @@ -0,0 +1,249 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.ConstantsBG +import code.api.util.APIUtil.getPropsAsIntValue +import code.api.util.{CallContext, Consent, NewStyle} +import code.consent.{ConsentStatus, Consents} +import code.signingbaskets.{SigningBasketMemberExecution, SigningBasketMemberState, SigningBasketX} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{AccountId, BankAccount, BankId, TransactionRequest, TransactionRequestId} +import net.liftweb.common.Full + +import scala.concurrent.Future +import scala.util.{Failure, Success} + +/** + * Carries out a signing basket's authorisation once its SCA has been answered: books each payment, + * one after another, and records what happened to each. + * + * What it guarantees, and what it does not. + * + * - Each member is claimed with one conditional update before it is touched, so two executors (the + * request that answered the SCA and a later resumption) never work on the same member at once. + * - A payment that already carries a transaction id is never booked again. The payment id is the + * idempotency key: this is what makes a resumption safe. It holds for the mapped connector, which + * records the transaction id in the same database as the booking. + * - An execution that stopped part way is resumed from where it stopped. A member left EXECUTING past + * the lease is UNKNOWN: it is reconciled by the transaction id if it has one, and otherwise left for + * an operator, never retried blindly. + * - A member that failed is retried automatically only on the mapped connector, and only a limited + * number of times. On any other connector a failure is recorded as UNKNOWN, because the connector + * may have booked before it failed, and nothing here can ask it. + * - The basket reaches ACTC only when every member is DONE. Otherwise it is EXECUTION_INCOMPLETE, + * reported as RCVD, and the members' own results say what happened. + * + * It does not make several payments atomic. Each is booked by its connector on its own, so a failure + * leaves the earlier ones booked, which is what the per-member results are there to show. + */ +object SigningBasketExecution extends MdcLoggable { + + import SigningBasketMemberState._ + + private def provider = SigningBasketX.signingBasketProvider.vend + + /** How many times a member that failed is claimed again before it is left for an operator. */ + private def maxAttempts: Int = getPropsAsIntValue("signing_basket_member_max_attempts", 3) + + private val awaitingAuthorisation = "RCVD" + + /** + * Executes the basket's members that are not yet DONE, in order, stopping at the first that does not + * finish. Returns true when every member is DONE and the basket has become ACTC. + */ + def execute(basketId: String, callContext: Option[CallContext]): Future[Boolean] = { + def loop(rest: List[SigningBasketMemberExecution]): Future[Boolean] = rest match { + case Nil => Future.successful(true) + case member :: tail if member.state == Done => loop(tail) + case member :: tail => executeMember(basketId, member, callContext).flatMap(done => if (done) loop(tail) else Future.successful(false)) + } + loop(provider.getSigningBasketMemberExecutions(basketId)).flatMap(allDone => finish(basketId, allDone)) + } + + /** 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] = { + provider.markStaleSigningBasketMembersUnknown(leaseSeconds) + val baskets = provider.getSigningBasketsAwaitingExecution(leaseSeconds, limit) + baskets.foldLeft(Future.successful(())) { (previous, basketId) => + previous.flatMap(_ => execute(basketId, None).transform { + case Failure(error) => + logger.error(s"Resuming the execution of signing basket $basketId failed", error) + Success(false) + case ok => ok + }.map(_ => ())) + }.map(_ => baskets.size) + } + + private def finish(basketId: String, allDone: Boolean): Future[Boolean] = Future { + val authorising = ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL + val incomplete = ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL + val actc = ConstantsBG.SigningBasketsStatus.ACTC.toString + if (allDone) { + val completed = provider.transitionSigningBasketStatus(basketId, authorising, actc).openOr(false) || + provider.transitionSigningBasketStatus(basketId, incomplete, actc).openOr(false) + // The members are free to join another basket only once the basket is final. + if (completed) provider.releaseSigningBasketMembers(basketId) + completed + } else { + provider.transitionSigningBasketStatus(basketId, authorising, incomplete) + false + } + } + + private def executeMember(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = + member.memberType match { + case PaymentType => executePayment(basketId, member, callContext) + case ConsentType => executeConsent(basketId, member, callContext) + case other => record(basketId, member, Set(Pending, Failed), Failed, s"Unknown member type $other").map(_ => false) + } + + /** + * Activates a consent: it becomes valid and is bound to the PSU, as if the PSU had authorised it on its + * own. Activation is idempotent (a consent already valid and bound to this PSU is simply DONE), so unlike a + * payment a consent may be claimed again from any state short of DONE, up to the attempts allowed. + */ + private def executeConsent(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = { + def finishWith(to: String, detail: String): Future[Boolean] = + record(basketId, member, Set(Executing, Unknown), to, detail).map(_ => to == Done) + val claimFrom = if (member.attempts == 0) Set(Pending) else if (member.attempts < maxAttempts) Set(Pending, Failed, Unknown) else Set(Pending) + record(basketId, member, claimFrom, Executing, "").flatMap { + case false => Future.successful(false) + case true => + basketPsu(basketId) match { + case None => finishWith(Failed, "The basket has no PSU, so there is nobody to bind the consent to") + case Some(psuUserId) => + val activation = for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(member.memberId)).map { + case Full(found) => found + case _ => throw new IllegalStateException("The consent cannot be read") + } + (psu, _) <- NewStyle.function.findByUserId(psuUserId, callContext) + outcome <- + if (consent.status == ConsentStatus.valid.toString && consent.userId == psuUserId) + Future.successful("Already valid") + else if (consent.status != ConsentStatus.received.toString) + Future.failed(new IllegalStateException(s"The consent is ${consent.status}, not waiting for authorisation")) + else for { + // The binding point, so the holdings check is repeated here: an account can change hands + // between the answer and the activation. + _ <- Consent.assertBerlinGroupConsentAccountsHeld(psu, consent, callContext) + _ <- BerlinGroupConsentActivation.activate(consent, psu, callContext) + } yield "Activated" + } yield outcome + activation.transform(Success(_)).flatMap { + case Success(detail) => finishWith(Done, detail) + case Failure(error) => finishWith(Failed, Option(error.getMessage).getOrElse(error.getClass.getSimpleName)) + } + } + } + } + + private def record(basketId: String, member: SigningBasketMemberExecution, from: Set[String], to: String, detail: String): Future[Boolean] = Future { + provider.transitionSigningBasketMemberExecution(basketId, member.memberType, member.memberId, from, to, detail).openOr(false) + } + + /** The states a member may be claimed from: a first attempt, or a retry that is allowed. */ + private def claimableFrom(member: SigningBasketMemberExecution): Set[String] = + if (member.attempts > 0 && member.attempts < maxAttempts) Set(Pending, Failed) else Set(Pending) + + private def bookedTransactionIds(transactionRequest: TransactionRequest): Boolean = + Option(transactionRequest.transaction_ids).exists(_.trim.nonEmpty) + + private def executePayment(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = { + def finishWith(to: String, detail: String): Future[Boolean] = + record(basketId, member, Set(Executing, Unknown), to, detail).map(_ => to == Done) + + if (member.state == Unknown) reconcile(basketId, member, callContext) + else record(basketId, member, claimableFrom(member), Executing, "").flatMap { + // Another executor holds it, or it is Failed past its attempts: not this one's to do. + case false => Future.successful(false) + case true => + NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { + case Failure(_) => finishWith(Failed, "The payment cannot be read") + case Success((payment, _)) if bookedTransactionIds(payment) => + // Already booked, by an earlier attempt that did not get to record it. + finishWith(Done, s"Already booked: transaction ${payment.transaction_ids}") + case Success((payment, _)) if payment.status != awaitingAuthorisation => + finishWith(Failed, s"The payment is ${payment.status}, not waiting for authorisation") + case Success((payment, _)) => book(basketId, member, payment, callContext, finishWith) + } + } + } + + private def book(basketId: String, + member: SigningBasketMemberExecution, + payment: TransactionRequest, + callContext: Option[CallContext], + finishWith: (String, String) => Future[Boolean]): Future[Boolean] = + NewStyle.function.checkBankAccountExists(BankId(payment.from.bank_id), AccountId(payment.from.account_id), callContext) + .transform(Success(_)).flatMap { + case Failure(_) => finishWith(Failed, "The debtor account cannot be found") + case Success((fromAccount, _)) => + val mapped = isMappedConnector(fromAccount, payment, callContext) + NewStyle.function.createTransactionAfterChallengeV210(fromAccount, payment, callContext).transform(Success(_)).flatMap { + case Success(_) => finishWith(Done, "Booked") + case Failure(error) => + // The connector failed. Whether it booked first is read from the payment: a transaction id + // means it did. Without one, the mapped connector is treated as not having booked, so the + // payment can be tried again; any other connector may have, so it is left UNKNOWN. + NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { + case Success((after, _)) if bookedTransactionIds(after) => finishWith(Done, s"Booked: transaction ${after.transaction_ids}") + case _ if mapped => finishWith(Failed, s"Booking failed: ${Option(error.getMessage).getOrElse(error.getClass.getSimpleName)}") + case _ => finishWith(Unknown, s"The connector failed and may have booked: ${Option(error.getMessage).getOrElse(error.getClass.getSimpleName)}") + } + } + } + + /** A member left UNKNOWN is DONE if its payment carries a transaction id, and is otherwise left for an operator. */ + private def reconcile(basketId: String, member: SigningBasketMemberExecution, callContext: Option[CallContext]): Future[Boolean] = + NewStyle.function.getTransactionRequestImpl(TransactionRequestId(member.memberId), callContext).transform(Success(_)).flatMap { + case Success((payment, _)) if bookedTransactionIds(payment) => + record(basketId, member, Set(Unknown), Done, s"Reconciled: transaction ${payment.transaction_ids}").map(_ => true) + case _ => Future.successful(false) + } + + /** Whether the connector that books this payment is the mapped one. Only it is retried automatically. */ + private def isMappedConnector(fromAccount: BankAccount, payment: TransactionRequest, callContext: Option[CallContext]): Boolean = + scala.util.Try { + code.bankconnectors.getConnectorNameAndMethodRouting( + "createTransactionAfterChallengeV210", + Array("fromAccount" -> fromAccount, "transactionRequest" -> payment, "callContext" -> callContext) + )._2 == "mapped" + }.getOrElse(false) +} diff --git a/obp-api/src/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..259b8e30aa 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,24 @@ 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 "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..793e4be700 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,16 @@ 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 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..f1f94beab6 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -27,39 +27,242 @@ 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.{ConsentDoesNotMatchUser, SigningBasketAuthorisationNotFound, SigningBasketMemberMixInvalid, SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} import code.bankconnectors.Connector 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} +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") - } - } map { - (_, callContext) - } map { - x => (unboxFullOrFail(x._1, callContext, RegulatedEntityNotDeleted, 400), x._2) + /** + * 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 + } + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + awaitingScaPaymentStatuses.contains(payment.status) + } + } yield payment.on_behalf_of_user_id.flatMap(Consent.present).filter(isPerson(_, cc)) + + /** + * Whether this user is a person rather than the calling TPP's own pseudo-user. A client-credentials + * token resolves to an auto-created user keyed on the consumer's own key, and a payment lodged on one + * records it as the user it was made for; it names nobody a basket could be bound to. + */ + private def isPerson(userId: String, cc: CallContext): Boolean = + Users.users.vend.getUserByUserId(userId).toOption + .forall(user => !cc.consumer.map(_.key.get).contains(user.idGivenByProvider)) + + // A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked, + // rejected or cancelled, or is being authorised some other way. + private val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED") + /** + * A consent may join a basket if the caller may address it under the rule consents use, it was + * created through the Berlin Group API, and it has not been authorised or ended. Returns the PSU the + * consent is bound to, if it is. + */ + private def admitConsent(consentId: String, cc: CallContext, callContext: Option[CallContext]): Future[Option[String]] = + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)).flatMap { + case Full(found) => Future.successful(found) + case _ => refuseMember(SigningBasketMemberNotFound, 400, callContext) + } + refusal = Consent.checkBerlinGroupConsentAccess( + consent.userId, consent.consumerId, + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get), + callerIsScaFrontEnd = false) + _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext) { + refusal.isEmpty && consent.apiStandard == ConstantsBG.berlinGroupVersion1.apiStandard + } + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + consent.status == ConsentStatus.received.toString + } + } yield Consent.present(consent.userId) + + /** + * Admit the members of a new basket, and say whom the basket is for. + * + * Every member must be one the caller may address, in a state SCA can still authorise. All members + * must be for the same PSU where they name one, and that must be the PSU the request names (a genuine + * PSU in the session, or PSU-ID) where it names one. Members that name nobody leave the PSU to be bound + * when an authorisation is started. + */ + def admitMembers(paymentIds: List[String], + consentIds: List[String], + cc: CallContext, + callContext: Option[CallContext]): Future[Option[String]] = + for { + paymentPsus <- paymentIds.foldLeft(Future.successful(List.empty[Option[String]])) { (acc, id) => + acc.flatMap(done => admitPayment(id, cc, callContext).map(done :+ _)) + } + consentPsus <- consentIds.foldLeft(Future.successful(List.empty[Option[String]])) { (acc, id) => + acc.flatMap(done => admitConsent(id, cc, callContext).map(done :+ _)) + } + namedPsu <- Consent.resolvePsuIdHeader(cc, callContext).map(_.orElse(Consent.genuinePsu(cc).map(_.userId))) + memberPsus = (paymentPsus ++ consentPsus).flatten.toSet + _ <- booleanToFuture(SigningBasketMemberMixInvalid, failCode = 400, cc = callContext) { + memberPsus.size <= 1 && namedPsu.forall(named => memberPsus.forall(_ == named)) + } + } yield namedPsu.orElse(memberPsus.headOption) + + /** + * The PSU an authorisation on this basket is for, bound to the basket. + * + * It decides whose challenge this is, which is also where the one-time password is sent, so it is + * not read off the session: under Berlin Group the caller is the TPP, and a client-credentials TPP + * resolves to a pseudo-user of its own. The order is Consent.resolveBerlinGroupPsu's: the PSU the + * basket already names, a genuine PSU in the session (Redirect), then the PSU-ID header (Embedded). + * A header that contradicts the basket's PSU gets the same answer as any other refusal to address + * the basket; with none of the three there is nobody to authorise for, which is the standard's + * PSU_CREDENTIALS_INVALID (401). + */ + def bindAuthorisingPsu(basket: SigningBasketContent, + cc: CallContext, + callContext: Option[CallContext]): Future[String] = + for { + headerPsuUserId <- Consent.resolvePsuIdHeader(cc, callContext) + psuUserId <- Consent.resolveBerlinGroupPsu( + basket.basket.psuUserId.getOrElse(""), Consent.genuinePsu(cc).map(_.userId), headerPsuUserId) match { + case Right(userId) => Future.successful(userId) + case Left(reason) => + val (failMsg, failCode) = + if (reason == ConsentDoesNotMatchUser) (SigningBasketNotFound, 403) else (reason, 401) + booleanToFuture(failMsg = failMsg, failCode = failCode, cc = callContext)(false).map(_ => "") + } + bound <- Future(SigningBasketX.signingBasketProvider.vend.bindSigningBasketPsu(basket.basket.basketId, psuUserId)) + _ <- booleanToFuture(failMsg = SigningBasketNotFound, failCode = 403, cc = callContext)(bound.openOr(false)) + } yield psuUserId + + /** + * An authorisation of this basket, by id. One issued for another basket, or for something that is + * not a signing basket, is not found: the connector's challenge lookup goes by challenge id alone. + */ + 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/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..73390f42e6 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -30,79 +30,195 @@ 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(";")) - } - paymentIds.getOrElse(Nil).map { paymentId => - MappedSigningBasketPayment.create.BasketId(entity.basketId).PaymentId(paymentId).saveMe() + // The basket and every member row are written inside one DB.use. Inside an HTTP request the + // connection is the request's own, whose rollback is not ours to call, so a failure part way is + // also undone by hand: nothing of a basket that was not fully created is left behind. + var created: Option[MappedSigningBasket] = None + val 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 } - consentIds.getOrElse(Nil).map { consentId => - MappedSigningBasketConsent.create.BasketId(entity.basketId).ConsentId(consentId).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_! } - entity + } + result match { + case Failure(_, Full(_: MemberAlreadyHeld), _) => Failure(SigningBasketMemberStatusInvalid) + case other => other } } - override def deleteSigningBasket(id: String): Box[Boolean] = { - MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map { - _.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save + override def createSigningBasketMemberExecutions(basketId: String, members: List[(String, String)]): Box[Boolean] = + tryo { + DB.use(DefaultConnectionIdentifier) { _ => + members.zipWithIndex.foreach { case ((memberType, memberId), position) => + val exists = MappedSigningBasketMemberExecution.find( + By(MappedSigningBasketMemberExecution.BasketId, basketId), + By(MappedSigningBasketMemberExecution.MemberType, memberType), + By(MappedSigningBasketMemberExecution.MemberId, memberId)).isDefined + if (!exists) + MappedSigningBasketMemberExecution.create + .BasketId(basketId).MemberType(memberType).MemberId(memberId) + .Position(position).State(SigningBasketMemberState.Pending).Detail("").Attempts(0) + .saveMe() + } + } + true + } + + override def getSigningBasketMemberExecutions(basketId: String): List[SigningBasketMemberExecution] = + MappedSigningBasketMemberExecution + .findAll(By(MappedSigningBasketMemberExecution.BasketId, basketId), OrderBy(MappedSigningBasketMemberExecution.Position, Ascending)) + .map(row => SigningBasketMemberExecution( + row.MemberType.get, row.MemberId.get, row.Position.get, row.State.get, Option(row.Detail.get).getOrElse(""), row.Attempts.get)) + + override def transitionSigningBasketMemberExecution(basketId: String, + memberType: String, + memberId: String, + from: Set[String], + to: String, + detail: String): Box[Boolean] = + tryo { + val m = MappedSigningBasketMemberExecution + val fromList = from.toList + // A claim is the move to EXECUTING, and counts as an attempt. + val attemptsSql = if (to == SigningBasketMemberState.Executing) s", ${m.Attempts._dbColumnNameLC} = ${m.Attempts._dbColumnNameLC} + 1" else "" + DB.runUpdate( + s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + + s"${m.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP$attemptsSql " + + s"WHERE ${m.BasketId._dbColumnNameLC} = ? AND ${m.MemberType._dbColumnNameLC} = ? AND ${m.MemberId._dbColumnNameLC} = ? " + + s"AND ${m.State._dbColumnNameLC} IN (${fromList.map(_ => "?").mkString(", ")})", + List[Any](to, detail.take(2000), basketId, memberType, memberId) ++ fromList) == 1 + } + + override def markStaleSigningBasketMembersUnknown(olderThanSeconds: Long): Box[Int] = + tryo { + val m = MappedSigningBasketMemberExecution + val cutoff = new java.sql.Timestamp(System.currentTimeMillis() - olderThanSeconds * 1000) + DB.runUpdate( + s"UPDATE ${m.dbTableName} SET ${m.State._dbColumnNameLC} = ?, ${m.Detail._dbColumnNameLC} = ?, " + + s"${m.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"WHERE ${m.State._dbColumnNameLC} = ? AND ${m.updatedAt._dbColumnNameLC} < ?", + List[Any](SigningBasketMemberState.Unknown, "The executor stopped before recording an outcome", SigningBasketMemberState.Executing, cutoff)) } + + override def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] = { + val cutoff = new java.util.Date(System.currentTimeMillis() - olderThanSeconds * 1000) + MappedSigningBasket.findAll( + ByList(MappedSigningBasket.Status, List(ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.EXECUTION_INCOMPLETE_INTERNAL)), + BySql[MappedSigningBasket](s"${MappedSigningBasket.updatedAt._dbColumnNameLC} < ?", IHaveValidatedThisSQL("signing-basket", "2026-10-06"), cutoff), + OrderBy(MappedSigningBasket.updatedAt, Ascending), + MaxRows(limit) + ).map(_.basketId) } + override def releaseSigningBasketMembers(basketId: String): Box[Boolean] = + tryo { MappedSigningBasketMemberClaim.bulkDelete_!!(By(MappedSigningBasketMemberClaim.BasketId, basketId)) } + + override def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] = + tryo { + DB.runUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} " + + s"SET ${MappedSigningBasket.Status._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?", + List(to, basketId, from)) == 1 + } + + override def bindSigningBasketPsu(basketId: String, psuUserId: String): Box[Boolean] = + tryo { + val bound = DB.runUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} " + + s"SET ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = ?, ${MappedSigningBasket.updatedAt._dbColumnNameLC} = CURRENT_TIMESTAMP " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? " + + s"AND (${MappedSigningBasket.PsuUserId._dbColumnNameLC} IS NULL OR ${MappedSigningBasket.PsuUserId._dbColumnNameLC} = '')", + List(psuUserId, basketId)) == 1 + // Not bound by this call: that is only a success if the basket was already bound to this PSU. + bound || MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basketId)).exists(_.psuUserId.contains(psuUserId)) + } + } -class MappedSigningBasket extends SigningBasketTrait with LongKeyedMapper[MappedSigningBasket] with IdPK { +class MappedSigningBasket extends SigningBasketTrait with LongKeyedMapper[MappedSigningBasket] with IdPK with CreatedUpdated { override def getSingleton = MappedSigningBasket object BasketId extends MappedUUID(this) object Status extends MappedString(this, 50) - - + // The consumer (TPP) that created the basket. Empty, or null, on a basket created before this was recorded. + object ConsumerId extends MappedString(this, 255) + // The PSU the basket is for, once known (named on creation, or bound when an authorisation starts). + object PsuUserId extends MappedString(this, 255) override def basketId: String = BasketId.get override def status: String = Status.get + override def consumerId: Option[String] = Option(ConsumerId.get).map(_.trim).filter(_.nonEmpty) + override def psuUserId: Option[String] = Option(PsuUserId.get).map(_.trim).filter(_.nonEmpty) } @@ -142,3 +258,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..d3e7602ece 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -38,17 +38,101 @@ 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] + + /** Baskets whose execution has not finished, oldest first: AUTHORISING or EXECUTION_INCOMPLETE. */ + def getSigningBasketsAwaitingExecution(olderThanSeconds: Long, limit: Int): List[String] + + /** + * Frees the payments and consents a basket was holding, so they can join another basket. Called when + * a basket reaches a final status. + */ + 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..2a11020864 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -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") + + 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 - // 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 + /** + * 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,665 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def } - feature(s"BG v1.3 - $createSigningBasket, $getSigningBasket, $getSigningBasketStatus, $deleteSigningBasket, $startSigningBasketAuthorisation, $getSigningBasketAuthorisation, $updateSigningBasketPsuData") { - scenario("Authentication User, test succeed", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, startSigningBasketAuthorisation, getSigningBasketAuthorisation, updateSigningBasketPsuData) { - // Create Signing Basket - val postJson = - s"""{ - | "paymentIds": [ - | "123qwert456789", - | "12345qwert7899" - | ] - |}""".stripMargin - - val requestPost = (V1_3_BG / "signing-baskets").POST <@ (user1) - val response: APIResponse = makePostRequest(requestPost, postJson) - Then("We should get a 201 ") - response.code should equal(201) + // ───────────────────────── the happy path, with real payments ───────────────────────── - val basketId = response.body.extract[SigningBasketResponseJson].basketId + feature(s"BG v1.3 - $createSigningBasket, $getSigningBasket, $getSigningBasketStatus, $deleteSigningBasket, $startSigningBasketAuthorisation, $getSigningBasketAuthorisation, $getSigningBasketScaStatus, $updateSigningBasketPsuData") { + scenario("a basket of real payments is created, read, authorised and its authorisation listed", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus, startSigningBasketAuthorisation, getSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket(paymentCount = 2) - // Get Signing Basket Then(s"We test the $getSigningBasket") - val requestGet = (V1_3_BG / "signing-baskets" / basketId).GET <@ (user1) - val responseGet = makeGetRequest(requestGet) + val responseGet = makeGetRequest(basketUrl(started.basketId).GET <@ (user1)) responseGet.code should be(200) - responseGet.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + responseGet.body.extract[SigningBasketGetResponseJson].transactionStatus should be("RCVD") // L4497-4518 - // Get Signing Basket Status Then(s"We test the $getSigningBasketStatus") - val requestGetStatus = (V1_3_BG / "signing-baskets" / basketId / "status").GET <@ (user1) - var responseGetStatus = makeGetRequest(requestGetStatus) - responseGetStatus.code should be(200) - responseGetStatus.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) - - // Delete Signing Basket - val requestDelete = (V1_3_BG / "signing-baskets" / basketId).DELETE <@ (user1) - val responseDelete = makeDeleteRequest(requestDelete) - responseDelete.code should be(204) - - responseGetStatus = makeGetRequest(requestGetStatus) - responseGetStatus.code should be(200) - responseGetStatus.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.CANC.toString.toLowerCase()) - - // Start Signing Basket Auth Flow - val postJsonAuth = s"""{}""".stripMargin - val requestAuth = (V1_3_BG / "signing-baskets" / basketId / "authorisations").POST <@ (user1) - val responseAuth = makePostRequest(requestAuth, postJsonAuth) - Then("We should get a 201 ") - responseAuth.code should equal(201) - responseAuth.body.extract[StartPaymentAuthorisationJson].scaStatus should - be(StrongCustomerAuthenticationStatus.received.toString) - val authorisationId = responseAuth.body.extract[StartPaymentAuthorisationJson].authorisationId - - // Get Signing Basket Auth Flow Status - val requestAuthStatus = (V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId).GET <@ (user1) - val responseAuthStatus = makeGetRequest(requestAuthStatus) - Then("We should get a 200 ") - responseAuthStatus.code should equal(200) - responseAuthStatus.body.extract[ScaStatusJsonV13].scaStatus should - be(responseAuth.body.extract[StartPaymentAuthorisationJson].scaStatus) - - // Get Signing Basket Authorisations - val requestGetAuths = (V1_3_BG / "signing-baskets" / "basketId" / "authorisations").GET <@ (user1) - val responseGetAuths = makeGetRequest(requestGetAuths) - Then("We should get a 200 ") - responseGetAuths.code should equal(200) - responseGetAuths.body.extract[AuthorisationJsonV13] - - // Failed due to unexisting paymentIds - val putJson = s"""{"scaAuthenticationData":"123"}""".stripMargin - val requestPut = (V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId).PUT <@ (user1) - val responsePut = makePutRequest(requestPut, putJson) - val error = s"$InvalidConnectorResponse" - And("error should be " + error) - responsePut.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith(error) + val responseStatus = makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user1)) + responseStatus.code should be(200) + (responseStatus.body \ "transactionStatus").extract[String] should be("RCVD") + + Then(s"We test the $getSigningBasketAuthorisation") + val responseAuths = makeGetRequest(authorisationsUrl(started.basketId).GET <@ (user1)) + responseAuths.code should be(200) + responseAuths.body.extract[AuthorisationJsonV13].authorisationIds should equal(List(started.authorisationId)) // L4827 + + Then(s"We test the $getSigningBasketScaStatus") + val responseAuthStatus = makeGetRequest(authorisationUrl(started.basketId, started.authorisationId).GET <@ (user1)) + responseAuthStatus.code should be(200) + (responseAuthStatus.body \ "scaStatus").extract[String] should be(StrongCustomerAuthenticationStatus.received.toString) + } + } + + // ───────────────────────── response shape: C1, C2, C3, C12 ───────────────────────── + + feature("BG v1.3 signing baskets - response shape follows the standard") { + scenario("C1: transactionStatus is upper case in every response that carries it (L4497-4518)", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus) { + val response = postBasket(s"""{"paymentIds":${idList(List(lodgePayment()))}}""") + response.code should equal(201) + (response.body \ "transactionStatus").extract[String] should equal("RCVD") + val basketId = response.body.extract[SigningBasketResponseJson].basketId + + (makeGetRequest(basketUrl(basketId).GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + (makeGetRequest((basketUrl(basketId) / "status").GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + } + + scenario("C12: the 201 carries Location (IG §8.1, Mandatory) and ASPSP-SCA-Approach (IG §8.1, Conditional on the approach being fixed)", BerlinGroupV1_3, SBS, createSigningBasket) { + val response = postBasket(s"""{"paymentIds":${idList(List(lodgePayment()))}}""") + response.code should equal(201) + val basketId = response.body.extract[SigningBasketResponseJson].basketId + val headers = response.headers.getOrElse(fail("the response has no headers")) + Option(headers.get("Location")).getOrElse(fail("Location is missing")) should endWith(s"/signing-baskets/$basketId") + Option(headers.get("ASPSP-SCA-Approach")) should not be empty + } + + scenario("C2: _links.scaStatus of a started authorisation is a {href} object (L4801, L10752)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + val response = startAuthorisation(basketId) + response.code should equal(201) + val authorisationId = (response.body \ "authorisationId").extract[String] + (response.body \ "scaStatus").extract[String] should equal("received") + 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 ───────────────────────── -} \ No newline at end of file + 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 member left UNKNOWN is reconciled by its transaction id, and is otherwise left alone", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val booked = lodgePayment() + val unbooked = lodgePayment() + val basketId = createBasket(List(booked, unbooked)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + answerAuthorisation(basketId, authorisationId).code should equal(200) + val afterBooking = balanceOf(ibanFrom) + storedBasketStatus(basketId) should equal(Some("ACTC")) + + // As if the executor stopped before it recorded either outcome: the basket is back to executing, + // and the first payment really was booked (it carries its transaction id), the second was not. + val provider = SigningBasketX.signingBasketProvider.vend + provider.transitionSigningBasketStatus(basketId, "ACTC", "AUTHORISING") + List(booked, unbooked).foreach(id => provider.transitionSigningBasketMemberExecution(basketId, "payment", id, Set("DONE"), "UNKNOWN", "test")) + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, unbooked)).openOrThrowException("payment") + .mStatus(awaitingSca).mTransactionIDs("").saveMe() + + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds) should be(false) + withClue("nothing was booked again: ") { balanceOf(ibanFrom) should equal(afterBooking) } + memberResults(basketId).map(r => r._1 -> r._2).toMap should equal(Map(booked -> "DONE", unbooked -> "UNKNOWN")) + storedBasketStatusRaw(basketId) should equal("EXECUTION_INCOMPLETE") + } + } + + feature("BG v1.3 signing baskets - an execution that stopped is resumed from where it stopped") { + scenario("S4: a basket claimed but never started is executed by the resumption, once, and only after the lease", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val payments = List(lodgePayment(), lodgePayment()) + val basketId = createBasket(payments) + val provider = SigningBasketX.signingBasketProvider.vend + // The state a crash leaves behind: the answer was accepted and the basket claimed, and nothing was booked. + provider.transitionSigningBasketStatus(basketId, "RCVD", "AUTHORISING") + provider.createSigningBasketMemberExecutions(basketId, payments.map("payment" -> _)) + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + + withClue("within the lease it is left alone: ") { + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(3600, 50), 60.seconds) + balanceOf(ibanFrom) should equal(fromBefore) + storedBasketStatusRaw(basketId) should equal("AUTHORISING") + } + Thread.sleep(1200) + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(1, 50), 60.seconds) + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + balanceOf(ibanTo) should equal(toBefore + 2 * 2001) + memberResults(basketId).map(r => (r._2, r._3)) should equal(List(("DONE", 1), ("DONE", 1))) + storedBasketStatus(basketId) should equal(Some("ACTC")) + + withClue("resuming again books nothing: ") { + Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.resumePending(1, 50), 60.seconds) + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + } + } + + scenario("S4: two resumptions at once book each payment once", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + import scala.concurrent.ExecutionContext.Implicits.global + val ibanFrom = ibanAccounts.head + val payments = List(lodgePayment(), lodgePayment()) + val basketId = createBasket(payments) + val provider = SigningBasketX.signingBasketProvider.vend + provider.transitionSigningBasketStatus(basketId, "RCVD", "AUTHORISING") + provider.createSigningBasketMemberExecutions(basketId, payments.map("payment" -> _)) + val fromBefore = balanceOf(ibanFrom) + Thread.sleep(1200) + val resumptions = (1 to 3).map(_ => Future(Await.result(code.api.berlin.group.v1_3.SigningBasketExecution.execute(basketId, None), 60.seconds))) + Await.result(Future.sequence(resumptions), 120.seconds) + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + memberResults(basketId).map(_._3) should equal(List(1, 1)) + } + } + + // ───────────────────────── concurrency ───────────────────────── + + feature("BG v1.3 signing baskets - a delete racing the final answer has exactly one winner") { + 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") + } + } + } + } + } +} 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..a5d56a6944 --- /dev/null +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -0,0 +1,231 @@ +/** +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).isDefined)) + val created = Await.result(Future.sequence(callers), 60.seconds) + withClue(s"round $round: ") { + created.count(identity) should equal(1) + MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.PaymentId, member)).size should equal(1) + } + } + } + } + + feature("each member of a basket has its own execution state") { + import SigningBasketMemberState._ + + scenario("members are recorded PENDING in the order given, once") { + val basket = newBasket() + val (first, second, consent) = (uuid(), uuid(), uuid()) + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, first), (PaymentType, second), (ConsentType, consent))) + .openOrThrowException("x") should be(true) + // Recording them again changes nothing. + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, second), (PaymentType, first))) + provider.getSigningBasketMemberExecutions(basket.basketId).map(m => (m.memberType, m.memberId, m.state, m.attempts)) should equal( + List((PaymentType, first, Pending, 0), (PaymentType, second, Pending, 0), (ConsentType, consent, Pending, 0))) + } + + scenario("a member moves only from a state the caller names, and a claim counts as an attempt") { + val basket = newBasket() + val payment = uuid() + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, payment))) + def move(from: Set[String], to: String, detail: String = "") = + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, payment, from, to, detail).openOrThrowException("x") + move(Set(Executing), Done) should be(false) + move(Set(Pending, Failed), Executing) should be(true) + move(Set(Pending, Failed), Executing) should be(false) + move(Set(Executing), Failed, "no funds") should be(true) + move(Set(Pending, Failed), Executing) should be(true) + move(Set(Executing), Done) should be(true) + val stored = provider.getSigningBasketMemberExecutions(basket.basketId).head + (stored.state, stored.attempts) should equal((Done, 2)) + } + + scenario("executors racing for one member have exactly one winner") { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 10).foreach { round => + val basket = newBasket() + val payment = uuid() + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, payment))) + val executors = (1 to 8).map(_ => Future( + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, payment, Set(Pending), Executing, "").openOr(false))) + withClue(s"round $round: ") { + Await.result(Future.sequence(executors), 60.seconds).count(identity) should equal(1) + provider.getSigningBasketMemberExecutions(basket.basketId).head.attempts should equal(1) + } + } + } + + scenario("a member still EXECUTING after the lease becomes UNKNOWN; one that finished does not") { + val basket = newBasket() + val (stuck, finished) = (uuid(), uuid()) + provider.createSigningBasketMemberExecutions(basket.basketId, List((PaymentType, stuck), (PaymentType, finished))) + List(stuck, finished).foreach(id => provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, id, Set(Pending), Executing, "")) + provider.transitionSigningBasketMemberExecution(basket.basketId, PaymentType, finished, Set(Executing), Done, "") + Thread.sleep(1200) + provider.markStaleSigningBasketMembersUnknown(1).openOrThrowException("x") should be >= 1 + provider.getSigningBasketMemberExecutions(basket.basketId).map(m => m.memberId -> m.state).toMap should equal( + Map(stuck -> Unknown, finished -> Done)) + } + + scenario("baskets whose execution has not finished are listed, oldest first") { + val stuck = newBasket() + val done = newBasket() + provider.transitionSigningBasketStatus(stuck.basketId, "RCVD", code.api.berlin.group.ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL) + provider.transitionSigningBasketStatus(done.basketId, "RCVD", "ACTC") + Thread.sleep(1200) + val awaiting = provider.getSigningBasketsAwaitingExecution(1, 100) + awaiting should contain(stuck.basketId) + awaiting should not contain done.basketId + provider.getSigningBasketsAwaitingExecution(3600, 100) should not contain stuck.basketId + } + } +} diff --git a/obp-api/src/test/scala/code/util/MappedClassNameTest.scala b/obp-api/src/test/scala/code/util/MappedClassNameTest.scala index 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,