Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions obp-api/src/main/scala/deletion/DeleteAccountCascade.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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) ::
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading