diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 0a1f1eb81f..aeacade05f 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -30,9 +30,11 @@ package deletion import code.accountattribute.MappedAccountAttribute import code.api.APIFailureNewStyle import code.api.util.APIUtil.fullBoxOrException +import code.api.util.{ConsentJWT, CustomJsonFormats, JwtUtil} import code.api.util.ErrorMessages.CouldNotDeleteCascade import code.bankconnectors.Connector import code.cards.MappedPhysicalCard +import code.consent.Consents import code.counterpartyattribute.CounterpartyAttribute import code.counterpartylimit.CounterpartyLimit import code.entitlement.MappedEntitlement @@ -43,11 +45,14 @@ import code.transactionrequests.MappedTransactionRequest import code.views.system.{AccountAccess, ViewDefinition} import code.webhook.MappedAccountWebhook import com.openbankproject.commons.model.{AccountId, BankId} +import com.openbankproject.commons.util.JsonAliases import deletion.DeletionUtil.databaseAtomicTask import net.liftweb.common.{Box, Empty, Full} import net.liftweb.db.DB -import net.liftweb.mapper.{By, ByList} +import net.liftweb.mapper.{By, ByList, NotBy} import net.liftweb.util.DefaultConnectionIdentifier +import net.liftweb.util.Helpers.tryo +import org.json4s.Formats import scala.collection.immutable.List @@ -57,7 +62,8 @@ object DeleteAccountCascade { // The tasks run in list order: the challenges are found through the account's transaction // requests, so they must go before deleteTransactionRequests and deleteTransactions remove them. val doneTasks = - deleteTransactionRequestChallenges(bankId, accountId) :: + deleteConsentChallenges(bankId, accountId) :: + deleteTransactionRequestChallenges(bankId, accountId) :: deleteTransactionRequests(bankId, accountId) :: deleteDoubleEntryBookTransactions(bankId, accountId) :: deleteTransactions(bankId, accountId) :: @@ -210,6 +216,26 @@ object DeleteAccountCascade { } } + // A consent names its accounts only in the views of its JWT, so the consents that still have + // challenges are read and those whose JWT names this account lose them. The consents are found + // through their challenges rather than through users: a Berlin Group consent awaiting its first + // challenge answer has no PSU bound to it yet. A consent bound to other accounts as well loses its + // challenges too: they record the authorisation of the consent as a whole. + private def deleteConsentChallenges(bankId: BankId, accountId: AccountId): Boolean = { + implicit val formats: Formats = CustomJsonFormats.formats + val consentIds = MappedExpectedChallengeAnswer.findAll(NotBy(MappedExpectedChallengeAnswer.ConsentId, "")) + .map(_.ConsentId.get).distinct + .flatMap(Consents.consentProvider.vend.getConsentByConsentId(_).toList) + .filter { consent => + JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) + .flatMap(payload => tryo(JsonAliases.parse(payload).extract[ConsentJWT])) + .exists(_.views.exists(view => view.bank_id == bankId.value && view.account_id == accountId.value)) + } + .map(_.consentId) + consentIds.isEmpty || MappedExpectedChallengeAnswer.bulkDelete_!!( + ByList(MappedExpectedChallengeAnswer.ConsentId, consentIds) + ) + } private def deleteTransactionRequestChallenges(bankId: BankId, accountId: AccountId): Boolean = { val transactionRequestIds = MappedTransactionRequest.findAll( By(MappedTransactionRequest.mFrom_BankId, bankId.value), diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala index 12973f60ca..5aa89419ab 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala @@ -66,14 +66,15 @@ trait BerlinGroupConsentFixtures extends BerlinGroupServerSetupV1_3 with Default def getNextMonthDate(): String = LocalDate.now().plusMonths(1).format(DateTimeFormatter.ISO_LOCAL_DATE) - /** One account, addressed by the first IBAN routing in the test data. */ - def bgConsentPostBody(): PostConsentJson = { - val acountRoutingIban = BankAccountRouting + /** One account, addressed by `iban`, or by the first IBAN routing in the test data. */ + def bgConsentPostBody(iban: Option[String] = None): PostConsentJson = { + val address = iban.getOrElse(BankAccountRouting .findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)).head + .accountRouting.address) PostConsentJson( access = ConsentAccessJson( accounts = Option(List(ConsentAccessAccountsJson( - iban = Some(acountRoutingIban.accountRouting.address), + iban = Some(address), bban = None, pan = None, maskedPan = None, @@ -131,8 +132,8 @@ trait BerlinGroupConsentFixtures extends BerlinGroupServerSetupV1_3 with Default * An unclaimed (PSU-less) consent lodged under testConsumer, built straight through the provider * -- the shape POST /consents leaves behind for a client_credentials caller. */ - def createUnclaimedBerlinGroupConsent(): ConsentTrait = { - val postJsonBody = bgConsentPostBody() + def createUnclaimedBerlinGroupConsent(iban: Option[String] = None): ConsentTrait = { + val postJsonBody = bgConsentPostBody(iban) val validUntilDate = BgSpecValidation.getDate(postJsonBody.validUntil) val createdConsent = Consents.consentProvider.vend.createBerlinGroupConsent( diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala new file mode 100644 index 0000000000..3a5252bd41 --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala @@ -0,0 +1,97 @@ +/** +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.v1_3.JSONFactory_BERLIN_GROUP_1_3.StartConsentAuthorisationJson +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.entitlement.Entitlement +import code.model.dataAccess.BankAccountRouting +import code.transactionChallenge.MappedExpectedChallengeAnswer +import com.openbankproject.commons.model.enums.AccountRoutingScheme +import net.liftweb.mapper.By +import org.scalatest.Tag + +/** + * A Berlin Group consent names its accounts when it is lodged, so once its PSU starts the + * authorisation the consent is bound to an account and has a challenge nobody has answered yet. + * Deleting that account with the cascading endpoint must take the challenge along; a challenge of a + * consent bound to another account stays. + */ +class DeleteAccountCascadeConsentChallengeTest extends BerlinGroupConsentFixtures { + + object DeleteAccountCascadeConsentChallenge extends Tag("DeleteAccountCascadeConsentChallenge") + + private def ibanOf(bankId: String, accountId: String): String = + BankAccountRouting.find( + By(BankAccountRouting.BankId, bankId), + By(BankAccountRouting.AccountId, accountId), + By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString) + ).openOrThrowException(s"no IBAN for $bankId/$accountId").accountRouting.address + + private def challengesOf(consentId: String): Int = + MappedExpectedChallengeAnswer.findAll(By(MappedExpectedChallengeAnswer.ConsentId, consentId)).size + + // The consent is lodged for one account and resourceUser1 starts its authorisation, which binds + // the PSU and mints the challenge; the challenge is left unanswered. + private def consentAwaitingItsChallenge(bankId: String, accountId: String): String = { + val consentId = createUnclaimedBerlinGroupConsent(Some(ibanOf(bankId, accountId))).consentId + val started = makePostRequest( + (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (user1), + """{"scaAuthenticationData":""}""") + started.code should equal(201) + started.body.extract[StartConsentAuthorisationJson].authorisationId should not be empty + consentId + } + + feature("Deleting an account by cascade deletes the challenges of the consents bound to it") { + scenario("A Berlin Group consent whose challenge is still unanswered", DeleteAccountCascadeConsentChallenge) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + Given("Two accounts resourceUser1 holds, each named by a consent awaiting its challenge") + val held = ibanAddressableAccountsHeldBy(resourceUser1).toList.sorted + held.size should be >= 2 + val (bankId, accountId) = held.head + val (otherBankId, otherAccountId) = held(1) + val consent = consentAwaitingItsChallenge(bankId, accountId) + val otherConsent = consentAwaitingItsChallenge(otherBankId, otherAccountId) + challengesOf(consent) should equal(1) + challengesOf(otherConsent) should equal(1) + + When("The first account is deleted with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + val request = (baseRequest / "obp" / "v4.0.0" / "management" / "cascading" / "banks" / bankId / + "accounts" / accountId).DELETE <@ (user1) + makeDeleteRequest(request).code should equal(200) + + Then("The challenge of the consent bound to the deleted account is gone") + challengesOf(consent) should equal(0) + And("The challenge of the consent bound to the other account is kept") + challengesOf(otherConsent) should equal(1) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala index f9b5b2f8bf..1f85427746 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala @@ -62,6 +62,8 @@ class DeleteAccountCascadeTest extends V400ServerSetup { object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.deleteAccountCascade)) + private val deleteAtFirstBank = "We delete the account at the first bank with the cascading endpoint" + lazy val bankId = randomBankId lazy val bankAccount = randomPrivateAccountViaEndpoint(bankId) lazy val addAccountJson = SwaggerDefinitionsJSON.createAccountRequestJsonV310.copy(user_id = resourceUser1.userId, balance = AmountOfMoneyJsonV121("EUR","0")) @@ -195,7 +197,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { val card = addCard(bankId) val otherBankCard = addCard(otherBankId) - When("We delete the account at the first bank with the cascading endpoint") + When(deleteAtFirstBank) Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value / "accounts" / accountId.value).DELETE <@ (user1) @@ -229,7 +231,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { ) otherOwnerEntitlements should equal(1L) - When("We delete the account at the first bank with the cascading endpoint") + When(deleteAtFirstBank) Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value / "accounts" / accountId.value).DELETE <@ (user1)