From 907742793a6f0148b8b50404b1da89adae892cb4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 12:55:21 +0200 Subject: [PATCH 01/63] fix: enforce UK consent check on aggregate AISP balances/transactions endpoints GET /aisp/balances and GET /aisp/transactions fetched every private account for the caller without validating the bearer token's UK consent binding first, unlike every sibling AISP endpoint (including their own single-account counterparts). A DirectLogin or OAuth2 token with no bound consent fell through into the account-fetch path and surfaced as a 500 Unknown Error instead of the expected 403 OBP-35035, and callers with real consent got real account data with no consent enforcement at all. Add the same checkUKConsent + passesPsd2Aisp guard already used by getAccountsAccountIdBalances and getAccountsAccountIdTransactions so all five real-data AISP endpoints share one consent contract. Also fix createTransactionsJsonNew to take the AccountId from the request path instead of deriving it from the first transaction's bank account, which returned a null AccountId whenever the account had no transactions. --- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 6 ++- .../JSONFactory_UKOpenBanking_401.scala | 2 +- .../UKOpenBankingV401AccountInfoTests.scala | 54 ++++++------------- 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 3d02a40b4c..3380f6a84d 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -2236,7 +2236,7 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { transactions.map(_.id), view.viewId, Some(cc)) - } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(account.bankId, transactions, moderatedAttributes, view) + } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(accountId.value, account.bankId, transactions, moderatedAttributes, view) } } resourceDocs += ResourceDoc( @@ -2305,6 +2305,8 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { case req @ GET -> `ukV401Prefix` / "aisp" / "balances" => EndpointHelpers.withUser(req) { (u, cc) => for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) } yield JSONFactory_UKOpenBanking_401.createBalancesJSON(accounts) @@ -3446,6 +3448,8 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { case req @ GET -> `ukV401Prefix` / "aisp" / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) (bank, _) <- NewStyle.function.getBank(BankId(defaultBankId), Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala index f8fb2ec4ce..97aad8f019 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -293,12 +293,12 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { } def createTransactionsJsonNew( + accountId: String, bankId: BankId, moderatedTransactions: List[ModeratedTransaction], attributes: List[TransactionAttribute], view: View ): TransactionsUKV401 = { - val accountId = moderatedTransactions.headOption.flatMap(_.bankAccount.map(_.accountId.value)).orNull val transactions = moderatedTransactions.map(t => transactionJson(accountId, bankId, t, attributes, Some(view))) TransactionsUKV401( Data = TransactionDataV401(transactions), diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index d90a65a074..bc2e9f29a5 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -12,12 +12,16 @@ import org.scalatest.Tag // seeded data -> 200/201/204 with real field values, error paths (unknown // consent/account), and a full consent create -> get -> delete -> get lifecycle. // -// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions -// call NewStyle.function.checkUKConsent, which requires a live Hydra OAuth2 -// introspection endpoint (see code.api.util.ConsentUtil.checkUKConsent) — there -// is no local test double for Hydra, so (mirroring UKOpenBankingV310AisTests' -// precedent for the same three v3.1 endpoints) only "unauthenticated -> 401" -// and "authenticated -> not 401" are asserted for those three. +// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions / +// getBalances / getTransactions call NewStyle.function.checkUKConsent, which +// requires a live Hydra OAuth2 introspection endpoint (see +// code.api.util.ConsentUtil.checkUKConsent) — there is no local test double for +// Hydra, so (mirroring UKOpenBankingV310AisTests' precedent for the same v3.1 +// endpoints) only "unauthenticated -> 401" and "authenticated -> not 401" are +// asserted for those five. getBalances / getTransactions previously skipped the +// consent check entirely and returned real data straight from a DirectLogin +// token, which let a token with no bound consent reach 500 instead of the +// 403 OBP-35035 every other AISP data endpoint gives it. // // The remaining 80 endpoints are still static spec-faithful stubs; their tests // are unchanged (two scenarios: authenticated -> fixed code, unauthenticated -> 401). @@ -247,23 +251,10 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } // ── BalancesApi ──────────────────────────────────────────────────── + // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). feature("UKOB v4.0.1 GET /aisp/balances") { - scenario("authenticated with real account -> 200 real balance", UKOpenBankingV401AccountInfo) { - val response = getAuthed("aisp", "balances") - response.code should equal(200) - // resourceUser1 owns accounts on several test banks (see TestConnectorSetup. - // createAccountRelevantResources), so testAccountId1's entry isn't necessarily - // first — find it rather than assume position 0. - val balances = (response.body \ "Data" \ "Balance").children - balances should not be empty - val myBalance = balances.find(b => (b \ "AccountId").extract[String] == acc) - myBalance shouldBe defined - (myBalance.get \ "Amount" \ "Currency").extract[String] should equal("EUR") - } - scenario("authenticated with no private accounts -> 200 empty Balance list", UKOpenBankingV401AccountInfo) { - val response = getAuthedAsUser2("aisp", "balances") - response.code should equal(200) - (response.body \ "Data" \ "Balance").children should be(empty) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "balances").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "balances").code should equal(401) @@ -333,23 +324,10 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { getUnauthed("aisp", "statements").code should equal(401) } } + // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). feature("UKOB v4.0.1 GET /aisp/transactions") { - scenario("authenticated with seeded transactions -> 200 real data", UKOpenBankingV401AccountInfo) { - val seeded = seedTransactions(testAccountId1) - val response = getAuthed("aisp", "transactions") - response.code should equal(200) - // resourceUser1 owns accounts on several test banks, but only testAccountId1 - // has seeded transactions here, so every returned entry should belong to it. - val transactions = (response.body \ "Data" \ "Transaction").children - transactions should not be empty - transactions.foreach(t => (t \ "AccountId").extract[String] should equal(acc)) - (transactions.head \ "Amount" \ "Currency").extract[String] should equal("EUR") - transactions.map(t => (t \ "TransactionId").extract[String]) should contain (seeded.head.id.value) - } - scenario("authenticated with no private accounts -> 200 empty Transaction list", UKOpenBankingV401AccountInfo) { - val response = getAuthedAsUser2("aisp", "transactions") - response.code should equal(200) - (response.body \ "Data" \ "Transaction").children should be(empty) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "transactions").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "transactions").code should equal(401) From 462e6088ed43356c83013f7620eb94fe17283bbe Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 15:02:33 +0200 Subject: [PATCH 02/63] fix: stop background server launch from hanging callers via process substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redirecting to > >(tee "$RUNTIME_LOG") makes the tee process inherit this script's own stdout. A caller that captures this script's output with out=$(./flushall_build_and_run.sh --background ...) never sees EOF, because the long-running server (and the tee backing the substitution) never exits on its own — the command substitution hangs forever even though the server started successfully. Redirect straight to the log file instead. --- flushall_build_and_run.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 13a8bfa6c5..dfffbf35db 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -155,15 +155,20 @@ JAVA_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED \ RUNTIME_LOG=/tmp/obp-api.log if [ "$RUN_BACKGROUND" = true ]; then - # Run in background with output to log file (tee'd to /tmp as well) - nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > >(tee "$RUNTIME_LOG") 2>&1 & + # Run in background with output redirected straight to a log file. Do NOT + # use `> >(tee "$RUNTIME_LOG")` here: process substitution's tee inherits + # this script's own stdout, so a caller that captures this script's output + # via `out=$(./flushall_build_and_run.sh --background ...)` never sees EOF + # — the substitution hangs forever, since the server (and thus the tee + # process backing the substitution) never exits on its own. + nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > "$RUNTIME_LOG" 2>&1 & SERVER_PID=$! echo "✓ HTTP4S server started in background" echo " PID: $SERVER_PID" - echo " Log: http4s-server.log (also $RUNTIME_LOG)" + echo " Log: $RUNTIME_LOG" echo "" echo "To stop the server: kill $SERVER_PID" - echo "To view logs: tail -f http4s-server.log" + echo "To view logs: tail -f $RUNTIME_LOG" else # Run in foreground (Ctrl+C to stop). Also tee output to /tmp so it can be # tailed from another terminal without taking over this one. From f26bd07177f8da688ffd81a02b75fb0fd3d753a8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 15:05:30 +0200 Subject: [PATCH 03/63] docs: remove stale Hydra references from UK v4.0.1 test comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkUKConsent no longer needs a live Hydra introspection endpoint — it checks the Bearer token's consent_id claim against an AUTHORISED consent. Two comments describing the balances/transactions scenarios still referred to the removed Hydra dependency; align them with the class doc above, which was already corrected during the merge from Simon/develop. --- .../v4_0_1/UKOpenBankingV401AccountInfoTests.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 2919b673c7..c32bcdeb2b 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -252,7 +252,7 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } // ── BalancesApi ──────────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). + // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (see class doc above). feature("UKOB v4.0.1 GET /aisp/balances") { scenario("authenticated", UKOpenBankingV401AccountInfo) { getAuthed("aisp", "balances").code should not equal (401) @@ -325,7 +325,7 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { getUnauthed("aisp", "statements").code should equal(401) } } - // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). + // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (see class doc above). feature("UKOB v4.0.1 GET /aisp/transactions") { scenario("authenticated", UKOpenBankingV401AccountInfo) { getAuthed("aisp", "transactions").code should not equal (401) From 11635278dc48a93d2f9d3ceb4b6019b484593043 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 15:45:13 +0200 Subject: [PATCH 04/63] test(uk-open-banking): assert real 403 for the 3 checkUKConsent-gated v4.0.1 endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop's Hydra removal (PR #2866) rewrote ConsentUtil.checkUKConsent to read the consent_id claim directly off the Bearer access token instead of calling an external Hydra introspection endpoint. That was the only blocker keeping getAccounts/getAccountsAccountIdBalances/getAccountsAccountIdTransactions on a weak "authenticated -> not 401" assertion (mirroring the same limitation in UKOpenBankingV310AisTests for the equivalent v3.1 endpoints). The OAuth1-signed test requests carry no Bearer JWT, so the consent_id claim lookup now fails deterministically with 403 ConsentIdClaimMissing — asserted directly (status code + error message) instead of the previous placeholder. --- .../UKOpenBankingV401AccountInfoTests.scala | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index d90a65a074..2a35118849 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -1,7 +1,9 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.ErrorMessages.ConsentIdClaimMissing import code.consent.Consents +import com.openbankproject.commons.model.ErrorMessage import org.json4s._ import org.scalatest.Tag @@ -12,12 +14,11 @@ import org.scalatest.Tag // seeded data -> 200/201/204 with real field values, error paths (unknown // consent/account), and a full consent create -> get -> delete -> get lifecycle. // -// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions -// call NewStyle.function.checkUKConsent, which requires a live Hydra OAuth2 -// introspection endpoint (see code.api.util.ConsentUtil.checkUKConsent) — there -// is no local test double for Hydra, so (mirroring UKOpenBankingV310AisTests' -// precedent for the same three v3.1 endpoints) only "unauthenticated -> 401" -// and "authenticated -> not 401" are asserted for those three. +// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions call +// NewStyle.function.checkUKConsent (code.api.util.ConsentUtil.checkUKConsent), which reads +// the `consent_id` claim off the Bearer access token — no external identity-provider call. +// These OAuth1-signed test requests carry no Bearer JWT, so the claim lookup deterministically +// fails with a 403 ConsentIdClaimMissing (see the scenario comments on those three features). // // The remaining 80 endpoints are still static spec-faithful stubs; their tests // are unchanged (two scenarios: authenticated -> fixed code, unauthenticated -> 401). @@ -103,10 +104,15 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } // ── AccountsApi ──────────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). + // checkUKConsent extracts the `consent_id` claim from the Bearer access token (no external + // Hydra call since Consent.checkUKConsent dropped the Hydra dependency). These OAuth1-signed + // test requests carry no Bearer JWT at all, so the claim lookup deterministically fails -> + // 403 ConsentIdClaimMissing, mirroring "authenticated but no bound consent" in production. feature("UKOB v4.0.1 GET /aisp/accounts") { - scenario("authenticated", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts").code should not equal (401) + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts") + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts").code should equal(401) @@ -132,8 +138,10 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/balances") { - scenario("authenticated", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", acc, "balances").code should not equal (401) + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", acc, "balances") + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts", acc, "balances").code should equal(401) @@ -236,10 +244,12 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } // ── TransactionsApi ──────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). + // See the "no external Hydra call" note above feature("UKOB v4.0.1 GET /aisp/accounts"). feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/transactions") { - scenario("authenticated", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", acc, "transactions").code should not equal (401) + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", acc, "transactions") + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts", acc, "transactions").code should equal(401) From f10d8b6e14ca4600ecead5721acfd8ec176c6fd8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 19:59:00 +0200 Subject: [PATCH 05/63] fix: print the bound PORT from background build/run scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither flushall_build_and_run.sh nor flushall_fast_build_and_run.sh printed the port the server actually binds, only its PID. smoke_test.sh's start_and_test() parses "PID: " and "PORT: " from the script's captured output to know where to poll /root; with no PORT line it fails fast with "未打印实际监听端口" even after the server started and bound successfully, leaving the server running with nothing to verify it or clean it up. Print the actual dev.port value read from props/default.props (falling back to 8080) right after the PID line in both scripts' background-mode branch. --- flushall_build_and_run.sh | 4 ++++ flushall_fast_build_and_run.sh | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index dfffbf35db..2d92353971 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -163,8 +163,12 @@ if [ "$RUN_BACKGROUND" = true ]; then # process backing the substitution) never exits on its own. nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > "$RUNTIME_LOG" 2>&1 & SERVER_PID=$! + # Report the port the server will actually bind (dev.port in props), so callers + # that capture this script's output (e.g. smoke_test.sh) can parse it out. + SERVER_PORT=$(grep -E '^dev.port=' obp-api/src/main/resources/props/default.props 2>/dev/null | cut -d= -f2) echo "✓ HTTP4S server started in background" echo " PID: $SERVER_PID" + echo " PORT: ${SERVER_PORT:-8080}" echo " Log: $RUNTIME_LOG" echo "" echo "To stop the server: kill $SERVER_PID" diff --git a/flushall_fast_build_and_run.sh b/flushall_fast_build_and_run.sh index 88669ab6e9..61449c3c1f 100755 --- a/flushall_fast_build_and_run.sh +++ b/flushall_fast_build_and_run.sh @@ -324,8 +324,12 @@ if [ "$RUN_BACKGROUND" = true ]; then # Run in background with output to log file nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > http4s-server.log 2>&1 & SERVER_PID=$! + # Report the port the server will actually bind (dev.port in props), so callers + # that capture this script's output (e.g. smoke_test.sh) can parse it out. + SERVER_PORT=$(grep -E '^dev.port=' obp-api/src/main/resources/props/default.props 2>/dev/null | cut -d= -f2) echo "✓ HTTP4S server started in background" echo " PID: $SERVER_PID" + echo " PORT: ${SERVER_PORT:-8080}" echo " Log: http4s-server.log" echo "" echo "To stop the server: kill $SERVER_PID" From 21e57e4aa8f9b61f0df94414f4764836954be30a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 17 Jul 2026 10:50:32 +0200 Subject: [PATCH 06/63] fix: put bankId before accountId in createTransactionsJsonNew params Matches the codebase-wide convention of bank-scoped params before account-scoped ones. --- .../api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala | 2 +- .../UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 3380f6a84d..039b31485d 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -2236,7 +2236,7 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { transactions.map(_.id), view.viewId, Some(cc)) - } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(accountId.value, account.bankId, transactions, moderatedAttributes, view) + } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(account.bankId, accountId.value, transactions, moderatedAttributes, view) } } resourceDocs += ResourceDoc( diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala index 97aad8f019..9be3cd5c42 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -293,8 +293,8 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { } def createTransactionsJsonNew( - accountId: String, bankId: BankId, + accountId: String, moderatedTransactions: List[ModeratedTransaction], attributes: List[TransactionAttribute], view: View From 94befc22df067445f7d76879552d923a01e57c87 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 08:06:45 +0200 Subject: [PATCH 07/63] fix: bind UK consent permissions to real accounts on authorisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createUKConsentJWT wrote every granted permission as ConsentView(bank_id=null, account_id=null, view_id=permission) at consent-creation time, before any account is known. That row can never match a real account (User.hasAccountAccess does plain bank_id/account_id equality, no wildcard), so a UK consent's declared Permissions had no effect on what could actually be read — access depended entirely on unrelated pre-existing AccountAccess grants. Add Consent.grantUKConsentAccountAccess, called from authoriseUKConsent once the PSU has selected accounts (via a new account_ids field on the authorise request body), mirroring how updateViewsOfBerlinGroupConsentJWT resolves Berlin Group's IBAN-keyed access into real per-account grants. Since UK consents are exercised via an opaque OAuth2 Bearer token rather than BG's per-request Consent-JWT header, the AccountAccess rows are granted eagerly at authorisation time instead of re-derived per request. Add a regression test: a consent scoped to ReadAccountsBasic grants that view but leaves ReadBalances locked. --- .../scala/code/api/util/ConsentUtil.scala | 61 +++++++++++++++++++ .../scala/code/api/v5_1_0/Http4s510.scala | 38 ++++++++---- .../UKOpenBankingV401AccountInfoTests.scala | 51 +++++++++++++++- 3 files changed, 138 insertions(+), 12 deletions(-) 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 d0ba73f44c..6573bf059d 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1107,6 +1107,67 @@ object Consent extends MdcLoggable { CertificateUtil.jwtWithHmacProtection(jwtClaims, secret) } + /** + * Binds a UK Open Banking account-access consent to the PSU-selected accounts. + * + * createUKConsentJWT (called from POST /account-access-consents, before any account is known) + * writes every granted permission as ConsentView(bank_id=null, account_id=null, view_id=permission) + * — a row that can never match a real account (see User.hasAccountAccess: plain equality on + * bank_id/account_id, no wildcard). Call this once the PSU has selected which accounts the + * consent applies to (currently: the UK authorise step, since OBP has no separate + * ASPSP-hosted account-selection UI) to replace those dead rows with real per-account + * ConsentViews, and to eagerly grant the corresponding AccountAccess rows — mirroring how + * updateViewsOfBerlinGroupConsentJWT resolves BG's IBAN-keyed access into real accounts. + */ + def grantUKConsentAccountAccess(user: User, + bankId: BankId, + accountIds: List[String], + consent: MappedConsent, + callContext: Option[CallContext]): Future[Box[MappedConsent]] = { + implicit val dateFormats = CustomJsonFormats.formats + val payloadToUpdate: Box[ConsentJWT] = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) + .map(com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT]) + + val permissions: List[String] = payloadToUpdate match { + case Full(consentJwt) => consentJwt.views.map(_.view_id).distinct + case _ => Nil + } + + val accountChecks: List[Future[Box[BankAccount]]] = accountIds.distinct.map { accountId => + Connector.connector.vend.checkBankAccountExists(bankId, AccountId(accountId), callContext).map(_._1) + } + + Future.sequence(accountChecks).map { boxes => + val error = s"$BankAccountNotFound BankId(${bankId.value})" + val validatedAccountIds: List[String] = boxes.map(_.openOrThrowException(error)).map(_.accountId.value) + + val newViews: List[ConsentView] = for { + accountId <- validatedAccountIds + permission <- permissions + } yield ConsentView(bank_id = bankId.value, account_id = accountId, view_id = permission, None) + + if (newViews.isEmpty) { + Empty + } else { + val updatedPayload = payloadToUpdate.map(_.copy(views = newViews)) + val jwtPayloadAsJson = compactRender(Extraction.decompose(updatedPayload)) + val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) + val jwt = CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret) + // Eagerly grant real AccountAccess now: UK consents are exercised via an opaque OAuth2 + // Bearer token (checkUKConsent), not the Consent-JWT header BG/OBP consents use to + // lazily re-derive access on every call — so the grant has to happen once, here. + updatedPayload.foreach { consentJwt => + grantAccessToViews(user, consentJwt) match { + case Failure(msg, _, _) => + logger.warn(s"grantUKConsentAccountAccess: grantAccessToViews reported: $msg") + case _ => + } + } + Consents.consentProvider.vend.setJsonWebToken(consent.consentId, jwt) + } + } + } + private def checkConsumerIsActiveAndMatchedUK(consent: ConsentJWT, consumerIdOfLoggedInUser: Option[String]): Box[Boolean] = { Consumers.consumers.vend.getConsumerByConsumerId(consent.aud) match { case Full(consumerFromConsent) if consumerFromConsent.isActive.get == true => // Consumer is active diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 1f172a3e95..60e21e337f 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -87,7 +87,9 @@ import scala.language.{higherKinds, implicitConversions} // UK Open Banking consent SCA (see authoriseUKConsentChallenge / authoriseUKConsent): // the challenge-start endpoint returns this; the authorise endpoint consumes the answer. case class UKConsentScaChallengeJsonV510(challenge_id: String, sca_status: String, sca_method: String) -case class PostUKConsentAuthoriseJsonV510(challenge_id: String, answer: String) +// account_ids: the accounts the PSU is selecting for this consent's granted permissions — +// see the Gap 4 remediation note above authoriseUKConsent (bankId comes from the URL BANK_ID). +case class PostUKConsentAuthoriseJsonV510(challenge_id: String, answer: String, account_ids: List[String]) object Http4s510 { @@ -4337,7 +4339,7 @@ object Http4s510 { // flip it to AUTHORISED — the missing "authorisation binding" step of the UK flow. // User-authenticated but role-free (the account holder is approving their own consent). val authoriseUKConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ POST -> `prefixPath` / "banks" / _ / "consents" / consentId / "authorise" => + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "consents" / consentId / "authorise" => EndpointHelpers.executeFuture(req) { implicit val cc: code.api.util.CallContext = req.callContext for { @@ -4355,6 +4357,12 @@ object Http4s510 { authJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostUKConsentAuthoriseJsonV510 ", 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[PostUKConsentAuthoriseJsonV510] } + // The PSU must select at least one account for the consented permissions to bind to — + // see grantUKConsentAccountAccess (Gap 4 remediation: previously the consent's + // Permissions were never bound to a real account and had zero enforcement effect). + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat The Json body should be the $PostUKConsentAuthoriseJsonV510 (account_ids must not be empty) ", 400, Some(cc)) { + authJson.account_ids.nonEmpty + } (_, _) <- NewStyle.function.getChallenge(authJson.challenge_id, Some(cc)) (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( ChallengeType.OBP_CONSENT_CHALLENGE, @@ -4377,9 +4385,14 @@ object Http4s510 { // createdByUserId (via ConsentJWT.copy) — the UK permission views are preserved. updatedJwt <- Future(Consent.updateUserIdOfBerlinGroupConsentJWT(user.userId, consentAfterBind, Some(cc))) .map(i => connectorEmptyResponse(i, Some(cc))) - _ <- Future(Consents.consentProvider.vend.setJsonWebToken(consentId, updatedJwt)) + consentWithUser <- Future(Consents.consentProvider.vend.setJsonWebToken(consentId, updatedJwt)) + .map(i => connectorEmptyResponse(i, Some(cc))) + // Bind the consented permissions to the PSU-selected accounts, replacing the + // (bank_id=null, account_id=null) dead views createUKConsentJWT wrote at consent + // creation time, and eagerly grant the corresponding AccountAccess rows. + consentWithAccountAccess <- Consent.grantUKConsentAccountAccess(user, BankId(bankIdStr), authJson.account_ids, consentWithUser, Some(cc)) .map(i => connectorEmptyResponse(i, Some(cc))) - updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED)) + updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentWithAccountAccess.consentId, ConsentStatus.AUTHORISED)) .map(i => connectorEmptyResponse(i, Some(cc))) } yield ConsentJsonV310(updatedConsent.consentId, updatedConsent.jsonWebToken, updatedConsent.status) } @@ -4395,22 +4408,25 @@ object Http4s510 { |Authorise a UK Open Banking account-access consent as the current (PSU) user, after SCA. | |The TPP first lodges the intent via `POST /account-access-consents`; the consent is - |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user. The PSU then - |starts SCA via `POST .../authorise/challenge` and submits the resulting `challenge_id` - |plus the OTP `answer` here. On a valid answer this binds the consent to the PSU and - |transitions it to ${ConsentStatus.AUTHORISED}, so subsequent UK data calls whose access - |token carries the `consent_id` claim pass the consent check. + |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user and no bound + |accounts. The PSU then starts SCA via `POST .../authorise/challenge` and submits the + |resulting `challenge_id` plus the OTP `answer`, together with the `account_ids` the PSU + |is selecting for this consent, here. On a valid answer this binds the consent to the PSU + |and to those accounts — every permission the consent declared is granted on each selected + |account — and transitions it to ${ConsentStatus.AUTHORISED}, so subsequent UK data calls + |whose access token carries the `consent_id` claim pass the consent check and are scoped to + |exactly the accounts and permissions the PSU approved. | |${userAuthenticationMessage(true)} | |""", - PostUKConsentAuthoriseJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "123"), + PostUKConsentAuthoriseJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "123", List("8ca8a7e4-6d05-4b21-a165-c02c39d77e55")), ConsentJsonV310( "9d429899-24f5-42c8-8565-943ffa6a7945", "eyJhbGciOiJIUzI1NiJ9.eyJ2aWV3cyI6W119.signature", "AUTHORISED" ), - List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidJsonFormat, InvalidChallengeAnswer, InvalidConnectorResponse, UnknownError), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidJsonFormat, InvalidChallengeAnswer, $BankAccountNotFound, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsent) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 50ce214ca5..4f0a02844b 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -1,12 +1,19 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.Constant import code.api.util.APIUtil.DateWithDayFormat import code.api.util.ErrorMessages.ConsentIdClaimMissing +import code.api.util.Consent import code.consent.Consents -import com.openbankproject.commons.model.ErrorMessage +import code.model.UserExtended +import code.views.Views +import com.openbankproject.commons.model.{BankIdAccountId, ErrorMessage, ViewId} import org.json4s._ import org.scalatest.Tag +import scala.concurrent.Await +import scala.concurrent.duration._ + // Test suite for UK Open Banking Read/Write v4.0.1 (AccountInfo). // // The 9 endpoints wired to real connector data (see Http4sUKOBv401AccountInfo) @@ -109,6 +116,48 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { deleteUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } } + // ── Consent.grantUKConsentAccountAccess (Gap 4 fix) ─────────────────── + // Regression test for the previously-unverified scenario: before this fix, + // createUKConsentJWT wrote every permission as ConsentView(bank_id=null, + // account_id=null, view_id=permission) — a row that could never match a real + // account (see User.hasAccountAccess: plain bank_id/account_id equality, no + // wildcard) — so a UK consent's declared Permissions had zero effect on what + // could actually be read. This exercises the fix at the same access-check + // layer checkViewAccessAndReturnView (and therefore every UK data endpoint) + // relies on, since the full HTTP path requires a Bearer JWT with a consent_id + // claim that this OAuth1-signed test suite cannot mint (see the comment above + // "GET /aisp/accounts" below). + feature("UKOB v4.0.1 Consent.grantUKConsentAccountAccess binds permissions to the selected account only") { + scenario("consent scoped to ReadAccountsBasic grants that view but not ReadBalances", UKOpenBankingV401AccountInfo) { + val userExtended = UserExtended(resourceUser1) + val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + + // Baseline: ServerSetupWithTestData's default view grants don't include the UK read views. + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + + val consentId = createRealConsent() // permissions = List("ReadAccountsBasic") only + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + + val result = Await.result( + Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, List(acc), consent, None), + 10.seconds) + result.isDefined should equal(true) + + // Granted: the account now has a real (non-null) AccountAccess row for the consented view. + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(true) + + // Not granted: ReadBalances was never in the consent's Permissions, so it must stay locked — + // this is the check GET /aisp/accounts/ACCOUNT_ID/balances relies on (checkViewAccessAndReturnView). + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_BALANCES_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + } + } + // ── AccountsApi ──────────────────────────────────────────────────── // checkUKConsent extracts the `consent_id` claim from the Bearer access token (no external // Hydra call since Consent.checkUKConsent dropped the Hydra dependency). These OAuth1-signed From 2c138a2a8cc76b2480c457b949ef14ce7ac6c2ba Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 08:23:01 +0200 Subject: [PATCH 08/63] fix: differentiate can_* permissions for UK and Berlin Group system views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 6 UK Open Banking v4.0.1 system views (ReadAccountsBasic/Detail, ReadBalances, ReadTransactionsBasic/Debits/Detail) previously all shared the generic SYSTEM_VIEW_PERMISSION_COMMON set, so Detail granted nothing beyond Basic and a consent scoped to Balances alone still exposed full transaction and counterparty data. Berlin Group's ReadAccountsBerlinGroup and ReadBalancesBerlinGroup had zero ViewPermission rows at all (pure membership gating), unlike ReadTransactionsBerlinGroup which already has a full permission set. Add one can_* permission constant per view in constant.scala, mapped from the UK v4.0.1 spec's Detail Permissions table, and wire each into MapperViews.applyDefaultsForSystemView in place of the shared COMMON/untouched branches. accountant keeps SYSTEM_VIEW_PERMISSION_COMMON unchanged, split into its own case. Note for reviewers: existing deployments with these views already created at boot will have stale ViewPermission rows from the old COMMON set (or none, for the two BG views) — factoryResetSystemView needs to be re-run per view, or a migration added, to pick up the new defaults. --- .../scala/code/api/constant/constant.scala | 70 +++++++++++++++++++ .../main/scala/code/views/MapperViews.scala | 61 +++++++++++++--- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index db2143d8d0..5150eee4a7 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -632,6 +632,76 @@ object Constant extends MdcLoggable { CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) + // UK Open Banking v4.0.1 system views — previously all six shared the generic + // SYSTEM_VIEW_PERMISSION_COMMON set, so "Detail" granted nothing beyond "Basic" and a + // consent scoped to Balances alone still exposed full transaction/counterparty data. + // Mapped from the UK v4.0.1 spec's Detail Permissions table (see + // standards-permissions-research/uk-v401/account-and-transaction-api-profile.html). + final val SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_LABEL, + CAN_SEE_BANK_ACCOUNT_TYPE, + CAN_SEE_BANK_ACCOUNT_CURRENCY, + CAN_SEE_BANK_ACCOUNT_BANK_NAME + ) + + final val SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION = SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION ++ List( + CAN_SEE_BANK_ACCOUNT_IBAN, + CAN_SEE_BANK_ACCOUNT_NUMBER, + CAN_SEE_BANK_ACCOUNT_SWIFT_BIC, + CAN_SEE_BANK_ACCOUNT_ROUTING_SCHEME, + CAN_SEE_BANK_ACCOUNT_ROUTING_ADDRESS + ) + + final val SYSTEM_READ_BALANCES_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_BALANCE, + CAN_QUERY_AVAILABLE_FUNDS + ) + + final val SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION = List( + CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT, + CAN_SEE_TRANSACTION_AMOUNT, + CAN_SEE_TRANSACTION_TYPE, + CAN_SEE_TRANSACTION_CURRENCY, + CAN_SEE_TRANSACTION_START_DATE, + CAN_SEE_TRANSACTION_FINISH_DATE, + CAN_SEE_TRANSACTION_STATUS + ) + + // ReadTransactionsDebits is direction-filtered at the query layer (see Gap 2 in the plan + // above) — it is not itself a wider or narrower view, so it shares Basic's field visibility. + final val SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + + final val SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION ++ List( + CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT, + CAN_SEE_OTHER_ACCOUNT_IBAN, + CAN_SEE_OTHER_ACCOUNT_BANK_NAME, + CAN_SEE_OTHER_ACCOUNT_NUMBER, + CAN_SEE_OTHER_ACCOUNT_KIND, + CAN_SEE_OTHER_ACCOUNT_SWIFT_BIC, + CAN_SEE_OTHER_ACCOUNT_NATIONAL_IDENTIFIER, + CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME, + CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS, + CAN_SEE_OTHER_BANK_ROUTING_SCHEME, + CAN_SEE_OTHER_BANK_ROUTING_ADDRESS + ) + + // Berlin Group NextGenPSD2 system views — these two previously had zero ViewPermission rows + // at all (pure membership gating), unlike ReadTransactionsBerlinGroup which already has a + // full permission set above. BG's access object is IBAN-keyed, so unlike UK's Basic/Detail + // split, the account identifier (IBAN) is included by default here. + final val SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_LABEL, + CAN_SEE_BANK_ACCOUNT_TYPE, + CAN_SEE_BANK_ACCOUNT_CURRENCY, + CAN_SEE_BANK_ACCOUNT_BANK_NAME, + CAN_SEE_BANK_ACCOUNT_IBAN + ) + + final val SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_BALANCE, + CAN_QUERY_AVAILABLE_FUNDS + ) + // Auditor system view: read-only on the account itself. The auditor can // see everything but cannot modify account data, counterparties, images, // locations, aliases, URLs, or initiate / approve payments. diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 93812381e3..a8b5ded66b 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -782,8 +782,17 @@ object MapperViews extends Views with MdcLoggable { SYSTEM_VIEW_PERMISSION_COMMON ) entity.isFirehose_(true) - case SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID | - SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID => + case SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION + ) entity case SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID => ViewPermission.resetViewPermissions( @@ -803,18 +812,52 @@ object MapperViews extends Views with MdcLoggable { SYSTEM_AUDITOR_VIEW_PERMISSION ) entity - case SYSTEM_ACCOUNTANT_VIEW_ID | - SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID | - SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID | - SYSTEM_READ_BALANCES_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => + case SYSTEM_ACCOUNTANT_VIEW_ID => ViewPermission.resetViewPermissions( entity, SYSTEM_VIEW_PERMISSION_COMMON ) entity + // UK Open Banking v4.0.1 system views — each gets the can_* set matching its place in + // the spec's Basic/Detail permission hierarchy (see Constant.SYSTEM_READ_*_VIEW_PERMISSION + // definitions). Previously all six shared SYSTEM_VIEW_PERMISSION_COMMON, so "Detail" + // granted nothing beyond "Basic" and Balances alone exposed full transaction data. + case SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_BALANCES_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_BALANCES_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION + ) + entity case _ => entity } From 2aa2478c5f874af7b17e07228a8b843c8a5830be Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 09:06:33 +0200 Subject: [PATCH 09/63] feat: add ReadTransactionsCredits UK Open Banking system view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UK v4.0.1 Permissions enum requires ReadTransactionsCredits as an independently-selectable code alongside ReadTransactionsDebits, but only Debits (and Basic/Detail) existed as a view constant. Add the missing view id and can_* permission set (shared with ReadTransactionsDebits and ReadTransactionsBasic, since direction is a query-parameter-shaped concern, not a field-visibility one), wire it into MapperViews.applyDefaultsForSystemView and the additional_system_views boot whitelist, and create it unconditionally in the sandbox data importer alongside the other UK/BG views for consistency. Direction filtering itself (returning only credit- or debit-direction transactions per which view the consent granted) is not wired into the transactions endpoint yet — documented as a follow-up in constant.scala, since it also depends on fixing a separate, pre-existing bug where JSONFactory_UKOpenBanking_401 always reports CreditDebitIndicator as "Credit" regardless of the actual transaction. Add exact permission-set regression coverage for all 6 UK + 2 BG system views to MappedViewsTest. --- .../main/scala/bootstrap/liftweb/Boot.scala | 1 + .../scala/code/api/constant/constant.scala | 20 ++++++++++-- .../scala/code/sandbox/OBPDataImport.scala | 8 +++-- .../main/scala/code/views/MapperViews.scala | 6 ++++ .../scala/code/views/MappedViewsTest.scala | 32 +++++++++++++++++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 54bcc7ae48..42013e4622 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -324,6 +324,7 @@ class Boot extends MdcLoggable { SYSTEM_READ_BALANCES_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID, SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID, + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID, SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 5150eee4a7..16dd57d036 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -161,6 +161,7 @@ object Constant extends MdcLoggable { final val SYSTEM_READ_BALANCES_VIEW_ID = "ReadBalances" final val SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID = "ReadTransactionsBasic" final val SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID = "ReadTransactionsDebits" + final val SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID = "ReadTransactionsCredits" final val SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID = "ReadTransactionsDetail" // Berlin Group final val SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID = "ReadAccountsBerlinGroup" @@ -182,6 +183,7 @@ object Constant extends MdcLoggable { SYSTEM_READ_BALANCES_VIEW_ID:: SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID:: SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID:: + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID:: SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID:: SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID:: SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID:: @@ -667,9 +669,23 @@ object Constant extends MdcLoggable { CAN_SEE_TRANSACTION_STATUS ) - // ReadTransactionsDebits is direction-filtered at the query layer (see Gap 2 in the plan - // above) — it is not itself a wider or narrower view, so it shares Basic's field visibility. + // ReadTransactionsDebits / ReadTransactionsCredits (UK v4.0.1 spec: independently-selectable + // Permissions codes) are direction-filtered, not field-filtered — a PSU who only grants + // ReadTransactionsCredits should see the same transaction fields as ReadTransactionsBasic, + // just restricted to credit-direction rows. Neither view widens or narrows field visibility, + // so both share Basic's permission set here. + // + // Enforcement mechanism (decided, not yet wired into the transactions endpoint): filter by + // which of the two view_ids the consent granted, resolved the same way + // Http4sUKOBv401AccountInfo.getAccountsAccountIdTransactions already resolves Basic-or-Detail + // via ViewNewStyle.checkViewsAccessAndReturnView — no new can_* permission string, since + // direction is a query-parameter-shaped concern, not a field-visibility one. Wiring this in + // requires the endpoint to also filter the returned transaction list by amount sign, which in + // turn depends on fixing JSONFactory_UKOpenBanking_401.transactionJson's separate, + // pre-existing CreditDebitIndicator hardcoding (always "Credit", never derived from the + // transaction) — tracked as a follow-up, out of scope for this view/permission gap. final val SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + final val SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION final val SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION ++ List( CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT, diff --git a/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala b/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala index 08d343d5eb..eee5069a61 100644 --- a/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala +++ b/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala @@ -409,6 +409,7 @@ trait OBPDataImport extends MdcLoggable { val readBalancesView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_BALANCES_VIEW_ID).asInstanceOf[Box[ViewType]] val readTransactionsBasicView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID).asInstanceOf[Box[ViewType]] val readTransactionsDebitsView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID).asInstanceOf[Box[ViewType]] + val readTransactionsCreditsView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID).asInstanceOf[Box[ViewType]] val readTransactionsDetailView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID).asInstanceOf[Box[ViewType]] // Berlin Group val readAccountsBerlinGroupView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID).asInstanceOf[Box[ViewType]] @@ -427,9 +428,10 @@ trait OBPDataImport extends MdcLoggable { readAccountsBasicView, readAccountsDetailView, readBalancesView, - readTransactionsBasicView, - readTransactionsDebitsView, - readTransactionsDetailView, + readTransactionsBasicView, + readTransactionsDebitsView, + readTransactionsCreditsView, + readTransactionsDetailView, readAccountsBerlinGroupView, readBalancesBerlinGroupView, readTransactionsBerlinGroupView, diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index a8b5ded66b..06d32c8eb4 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -852,6 +852,12 @@ object MapperViews extends Views with MdcLoggable { SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION ) entity + case SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION + ) + entity case SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => ViewPermission.resetViewPermissions( entity, diff --git a/obp-api/src/test/scala/code/views/MappedViewsTest.scala b/obp-api/src/test/scala/code/views/MappedViewsTest.scala index 35f58da174..9ea83e2934 100644 --- a/obp-api/src/test/scala/code/views/MappedViewsTest.scala +++ b/obp-api/src/test/scala/code/views/MappedViewsTest.scala @@ -95,7 +95,39 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{ MapperViews.factoryResetSystemView(ViewId("does-not-exist")) shouldBe Empty } + // Regression coverage for the UK Open Banking / Berlin Group views-permissions gap + // remediation (Gap 1, 2, 5): each of these views previously either shared the generic + // SYSTEM_VIEW_PERMISSION_COMMON set (so "Detail" granted nothing beyond "Basic") or had no + // ViewPermission rows at all (the two BG views). Assert each view's allowed_actions match + // its target set exactly — no more, no less. + scenario("UK and Berlin Group system views have exact, differentiated can_* permission sets") { + // UK/BG views are opt-in (created on demand), not unconditionally present like auditor — + // getOrCreateSystemView creates them fresh with current code defaults; afterEach's + // ViewDefinition.bulkDelete_!! guarantees no stale permissions leak in between scenarios. + def actionsOf(viewId: String): Set[String] = + MapperViews.getOrCreateSystemView(viewId) + .openOrThrowException(s"$viewId should be a known system view") + .allowed_actions.toSet + actionsOf(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) should equal(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) should equal(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should equal(Constant.SYSTEM_READ_BALANCES_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID) should equal(Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID) should equal(Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION.toSet) + + Then("Detail must be a strict superset of Basic (never narrower), for both Accounts and Transactions") + Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION.toSet should contain allElementsOf Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION + Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION.toSet.size should be > Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION.toSet.size + Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet should contain allElementsOf Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet.size should be > Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION.toSet.size + + Then("Balances must not carry transaction- or counterparty-visibility permissions") + actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should equal(Set(Constant.CAN_SEE_BANK_ACCOUNT_BALANCE, Constant.CAN_QUERY_AVAILABLE_FUNDS)) + } } From 961addbb0c7392fa10f3f102b26ac73d6b980127 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 09:23:32 +0200 Subject: [PATCH 10/63] docs: fix stale additional_system_views value list in props template The sample.props.template's documented list of valid additional_system_views values had drifted from Boot.scala's actual whitelist: it was missing ReadBalancesBerlinGroup and ReadTransactionsBerlinGroup (already live for a while) and the newly-added ReadTransactionsCredits. --- obp-api/src/main/resources/props/sample.props.template | 3 +++ 1 file changed, 3 insertions(+) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 12356d2cb6..37629af6d9 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1275,8 +1275,11 @@ database_messages_scheduler_interval=3600 ReadBalances,\ ReadTransactionsBasic,\ ReadTransactionsDebits,\ + ReadTransactionsCredits,\ ReadTransactionsDetail, \ ReadAccountsBerlinGroup, \ + ReadBalancesBerlinGroup, \ + ReadTransactionsBerlinGroup, \ InitiatePaymentsBerlinGroup # ----------------------------------------------------------------------------- From fdb91f72ec73e4b929d2b382af7036a12c2e5d92 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 09:29:50 +0200 Subject: [PATCH 11/63] docs: document the consent-layer vs view-layer permission boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a bug fix — a design guard-rail. Berlin Group's frequencyPerDay, recurringIndicator, and validUntil, and UK's TransactionFromDateTime, ToDateTime, and ExpirationDateTime already live on the consent record (ConsentJWT), never as a view can_* permission. As the UK/BG system view permission sets get filled in, it's easy to reach for a can_* string to express a consent's time-boxing or access-frequency limit (e.g. a hypothetical can_see_transactions_last_90_days) — that conflates the two layers. Add a note at both ends (ConsentJWT and the SYSTEM_READ_*_VIEW_PERMISSION definitions) so future changes don't cross this boundary. --- obp-api/src/main/scala/code/api/constant/constant.scala | 7 +++++++ obp-api/src/main/scala/code/api/util/ConsentUtil.scala | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 16dd57d036..eddc6ecb04 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -634,6 +634,13 @@ object Constant extends MdcLoggable { CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) + // Design boundary for every SYSTEM_READ_*_VIEW_PERMISSION set below: these express what + // *fields* a view exposes, never a consent's time-boxing or access-frequency limit. BG's + // frequencyPerDay/recurringIndicator/validUntil and UK's TransactionFromDateTime/ToDateTime/ + // ExpirationDateTime belong on the consent record (see ConsentJWT in ConsentUtil.scala), not + // here — do not add a can_* string like "can_see_transactions_last_90_days" to narrow a view + // by date range or usage count; that's the consent's job, applied before the view is reached. + // UK Open Banking v4.0.1 system views — previously all six shared the generic // SYSTEM_VIEW_PERMISSION_COMMON set, so "Detail" granted nothing beyond "Basic" and a // consent scoped to Balances alone still exposed full transaction/counterparty data. 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 6573bf059d..94ff5ef545 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -40,6 +40,14 @@ import java.util.Date import scala.collection.immutable.{List, Nil} import scala.concurrent.Future +// Design boundary (not enforced by the compiler — keep it that way by convention): consent-layer +// attributes belong on the consent record, never as a View can_* permission. BG's +// frequencyPerDay/recurringIndicator/validUntil and UK's TransactionFromDateTime/ToDateTime/ +// ExpirationDateTime (exp/nbf above) already live here for that reason. Do not add a can_* string +// like "can_see_transactions_last_90_days" to a system view's permission set to express a +// consent's time-boxing or access-frequency limit — that's a property of *this* consent, not of +// the account's view. See MapperViews.applyDefaultsForSystemView / Constant.SYSTEM_READ_*_VIEW_PERMISSION +// for where view-layer can_* sets are defined. case class ConsentJWT(createdByUserId: String, sub: String, // An identifier for the user, unique among all OBP-API users and never reused iss: String, // The Issuer Identifier for the Issuer of the response. From c0fe7f16373dcada5addeadc263ee2958cff564e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 09:44:29 +0200 Subject: [PATCH 12/63] fix(berlin-group): unblock consent SCA authorisation flow GET /obp/v5.1.0/user/current/consents/CONSENT_ID hard-required an exact mUserId match, so a PSU could never view a Berlin Group consent before completing SCA (BG consents are created via client_credentials with no owner yet). Relax the check to also allow consents that have no owner assigned. Also fix createStartConsentAuthorisationJson returning authenticationMethodId as the authorisationId instead of challengeId -- the PUT endpoint resolves the authorisation by challengeId, so any caller following the documented response field got a 400 on the confirmation step. --- .../api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 2 +- obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 3a36351c17..709b14a4ed 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 @@ -724,7 +724,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def createStartConsentAuthorisationJson(consent: ConsentTrait, challenge: ChallengeTrait) : StartConsentAuthorisationJson = { StartConsentAuthorisationJson( scaStatus = challenge.scaStatus.map(_.toString).getOrElse("None"), - authorisationId = challenge.authenticationMethodId.getOrElse("None"), + authorisationId = challenge.challengeId, pushMessage = "started", //TODO Not implement how to fill this. _links = ScaStatusJsonV13(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/consents/${consent.consentId}/authorisations/${challenge.challengeId}")//TODO, Not sure, what is this for?? ) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 60e21e337f..e2affd0100 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4594,7 +4594,7 @@ object Http4s510 { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, failCode = 404, cc = Some(cc)) { - consent.mUserId == cc.userId + consent.mUserId == cc.userId || Option(consent.userId).forall(_.isBlank) } } yield JSONFactory510.getConsentInfoJson(consent) } From e20136576679a943b0bc3ae4774570f099c9e37c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 09:44:51 +0200 Subject: [PATCH 13/63] test(berlin-group): cover the unclaimed-consent SCA happy path end to end Create a PSU-less Berlin Group consent directly via the provider (mirroring how POST /consents builds one for a client_credentials caller), then exercise the full authorisation flow: another logged-in user can view it before SCA, starting the authorisation returns a resolvable authorisationId, and submitting the correct OTP claims the consent for the answering PSU. A second scenario documents that a wrong OTP is rejected with 400 and leaves the consent unclaimed. --- .../AccountInformationServiceAISApiTest.scala | 123 +++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index f9d5a6b697..140999ffc1 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -3,13 +3,16 @@ package code.api.berlin.group.v1_3 import org.json4s._ import code.api.Constant import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID} +import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ import code.api.berlin.group.v1_3.Http4sBGv13AIS import code.api.util.APIUtil import code.api.util.APIUtil.OAuth._ +import code.api.berlin.group.v1_3.model.ScaStatusResponse +import code.api.util.Consent import code.api.util.ErrorMessages._ import code.api.v4_0_0.PostViewJsonV400 -import code.consent.ConsentStatus +import code.consent.{ConsentStatus, ConsentTrait, Consents} import code.model.dataAccess.BankAccountRouting import code.setup.{APIResponse, DefaultUsers} import com.github.dwickern.macros.NameOf.nameOf @@ -21,6 +24,8 @@ import org.scalatest.Tag import java.time.LocalDate import java.time.format.DateTimeFormatter +import scala.concurrent.Await +import scala.concurrent.duration._ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 with DefaultUsers { @@ -762,6 +767,120 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit val responseStartConsentAuthorisation = makePutRequest(requestStartConsentAuthorisation, """{"confirmationCode":"confirmationCode"}""") responseStartConsentAuthorisation.code should be (200) } - } + } + + // Builds an unclaimed (PSU-less) Berlin Group consent directly via the provider, mirroring + // how POST /consents builds one for a client_credentials caller (createdByUser = None). + def createUnclaimedBerlinGroupConsent(): ConsentTrait = { + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + val acountRoutingIban = accountsRoutingIban.head + val postJsonBody = PostConsentJson( + access = ConsentAccessJson( + accounts = Option(List(ConsentAccessAccountsJson( + iban = Some(acountRoutingIban.accountRouting.address), + bban = None, + pan = None, + maskedPan = None, + msisdn = None, + currency = None, + ))), + balances = None, + transactions = None, + availableAccounts = None, + allPsd2 = None + ), + recurringIndicator = true, + validUntil = getNextMonthDate(), + frequencyPerDay = 4, + combinedServiceIndicator = Some(false) + ) + val validUntilDate = BgSpecValidation.getDate(postJsonBody.validUntil) + + val createdConsent = Consents.consentProvider.vend.createBerlinGroupConsent( + user = None, + consumer = Some(testConsumer), + recurringIndicator = postJsonBody.recurringIndicator, + validUntil = validUntilDate, + frequencyPerDay = postJsonBody.frequencyPerDay, + combinedServiceIndicator = postJsonBody.combinedServiceIndicator.getOrElse(false), + apiStandard = Some(ConstantsBG.berlinGroupVersion1.apiStandard), + apiVersion = Some(ConstantsBG.berlinGroupVersion1.apiShortVersion) + ).openOrThrowException("test consent creation failed") + + val consentJWT = Await.result( + Consent.createBerlinGroupConsentJWT( + None, + postJsonBody, + createdConsent.secret, + createdConsent.consentId, + Some(testConsumer.consumerId.get), + Some(validUntilDate), + None + ), + 10.seconds + ).openOrThrowException("test consent JWT creation failed") + Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT) + + createdConsent + } + + feature(s"BG v1.3 - unclaimed consent SCA (regression: GET /obp/v5.1.0/user/current/consents/CONSENT_ID 404 before SCA, wrong authorisationId from ${startConsentAuthorisationTransactionAuthorisation.name})") { + scenario("Unclaimed consent: viewable pre-SCA by any user, authorisable, and claimed by the answering PSU on correct OTP", BerlinGroupV1_3, startConsentAuthorisationTransactionAuthorisation, updateConsentsPsuDataTransactionAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + + val createdConsent = createUnclaimedBerlinGroupConsent() + Option(createdConsent.userId).forall(_.isBlank) should be (true) + val consentId = createdConsent.consentId + + Then("A different logged-in user can GET the unclaimed consent — this used to 404 (Bug A)") + val requestGetConsent = (baseRequest / "obp" / "v5.1.0" / "user" / "current" / "consents" / consentId).GET <@ (user2) + val responseGetConsent = makeGetRequest(requestGetConsent) + responseGetConsent.code should be (200) + + Then(s"We test the $startConsentAuthorisationTransactionAuthorisation") + val requestStartConsentAuthorisation = (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (user1) + val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""") + responseStartConsentAuthorisation.code should be (201) + val authorisationId = responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("The returned authorisationId must resolve on GET — this used to be the wrong field (Bug C)") + val requestGetConsentScaStatus = (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).GET <@ (user1) + val responseGetConsentScaStatus = makeGetRequest(requestGetConsentScaStatus) + responseGetConsentScaStatus.code should be (200) + + Then(s"We submit the correct OTP to $updateConsentsPsuDataTransactionAuthorisation and the consent becomes valid, owned by the answering PSU") + val requestUpdatePsuData = (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (user1) + val responseUpdatePsuData = makePutRequest(requestUpdatePsuData, """{"scaAuthenticationData":"123"}""") + responseUpdatePsuData.code should be (200) + responseUpdatePsuData.body.extract[ScaStatusResponse].scaStatus should be ("valid") + + val updatedConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + updatedConsent.userId should be (resourceUser1.userId) + updatedConsent.status should be (ConsentStatus.valid.toString) + } + + scenario("Unclaimed consent: an incorrect OTP is rejected with 400 and the consent stays unclaimed (documents that updateConsentUser in updateConsentsPsuDataAll is never reached on a failed challenge answer, unrelated to this fix)", BerlinGroupV1_3, updateConsentsPsuDataTransactionAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + + val createdConsent = createUnclaimedBerlinGroupConsent() + val consentId = createdConsent.consentId + + val requestStartConsentAuthorisation = (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (user1) + val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""") + responseStartConsentAuthorisation.code should be (201) + val authorisationId = responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("We submit a wrong OTP") + val requestUpdatePsuData = (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (user1) + val responseUpdatePsuData = makePutRequest(requestUpdatePsuData, """{"scaAuthenticationData":"wrong-otp"}""") + responseUpdatePsuData.code should be (400) + responseUpdatePsuData.body.extract[ErrorMessagesBG].tppMessages.head.text should include ("OBP-40016") + + Then("The consent is not claimed — validateChallengeAnswerC4 fails the Box before updateConsentUser runs") + val updatedConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + Option(updatedConsent.userId).forall(_.isBlank) should be (true) + updatedConsent.status should be (ConsentStatus.received.toString) + } + } } \ No newline at end of file From c653f60f1a1e59252d4cafba4b10014f265aacfb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 13:26:49 +0200 Subject: [PATCH 14/63] fix: return spec four-letter status codes on UK v4.0.1 consent responses UK v4.0.1 requires AWAU/AUTH/RJCT/CANC/EXPD on the wire, but OBP was returning the internal long enum names (AWAITINGAUTHORISATION, AUTHORISED, REVOKED) verbatim -- disagreeing with the endpoint's own documented example response, which already showed AWAU. Add a status-code mapping at the JSON serialization boundary only; internal storage keeps the long names, matching how BG and OBP standard consents already work. REVOKED maps to CANC since OBP does not otherwise distinguish PSU-dashboard-cancel from AISP-DELETE-revoke and the spec has no other status for that distinction. Also add the StatusReason array required by OBReadConsentResponse1, which was previously present only in the hardcoded ResourceDoc example, never actually populated in real responses. This is a breaking wire-format change for any caller currently depending on the long status-name strings. --- .../JSONFactory_UKOpenBanking_401.scala | 34 ++++++++++++++++++- .../UKOpenBankingV401AccountInfoTests.scala | 8 ++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala index 9be3cd5c42..950cac825a 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -132,10 +132,15 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { // --------------------------------------------------------------------- // Consent // --------------------------------------------------------------------- + case class StatusReasonV401( + StatusReasonCode: String, + StatusReasonDescription: String + ) case class ConsentDataV401( ConsentId: String, CreationDateTime: String, Status: String, + StatusReason: List[StatusReasonV401], StatusUpdateDateTime: String, Permissions: List[String], ExpirationDateTime: String, @@ -319,6 +324,31 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { ) } + // v4.0.1 uses ISO 20022-style four-letter status codes on the wire (AWAU/AUTH/RJCT/CANC/EXPD), + // whereas OBP stores the long enum names (AWAITINGAUTHORISATION/AUTHORISED/REJECTED/REVOKED/EXPIRED). + // Map at the serialization boundary only — storage keeps the long names. REVOKED maps to CANC + // because the spec's revoke-side status is CANC and OBP does not distinguish dashboard-cancel from + // AISP-DELETE-revoke. Unknown/other statuses pass through unchanged so nothing is silently hidden. + def ukConsentStatusCode(status: String): String = status.toUpperCase match { + case "AWAITINGAUTHORISATION" => "AWAU" + case "AUTHORISED" => "AUTH" + case "REJECTED" => "RJCT" + case "REVOKED" => "CANC" + case "EXPIRED" => "EXPD" + case _ => status + } + + // Minimal StatusReason per the current status, mirroring the spec's own example wording/codes + // (OBReadConsentResponse1). Richer per-transition reasons can follow later. + private def ukConsentStatusReason(fourLetterCode: String): List[StatusReasonV401] = fourLetterCode match { + case "AWAU" => List(StatusReasonV401("U036", "Waiting for completion of consent authorisation to be completed by user")) + case "AUTH" => List(StatusReasonV401("U110", "The account access consent has been successfully authorised")) + case "RJCT" => List(StatusReasonV401("U111", "The account access consent has been rejected")) + case "CANC" => List(StatusReasonV401("U112", "The account access consent has been cancelled")) + case "EXPD" => List(StatusReasonV401("U113", "The account access consent has passed its expiry date")) + case _ => Nil + } + def createConsentResponseJSON( consentId: String, creationDateTime: String, @@ -330,11 +360,13 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { transactionToDateTime: String, selfPath: String ): ConsentResponseV401 = { + val statusCode = ukConsentStatusCode(status) ConsentResponseV401( Data = ConsentDataV401( ConsentId = consentId, CreationDateTime = creationDateTime, - Status = status, + Status = statusCode, + StatusReason = ukConsentStatusReason(statusCode), StatusUpdateDateTime = statusUpdateDateTime, Permissions = permissions, ExpirationDateTime = expirationDateTime, diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 4f0a02844b..5ab9997c38 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -77,6 +77,9 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { consentId should not be empty Consents.consentProvider.vend.getConsentByConsentId(consentId).isDefined should equal(true) (response.body \ "Data" \ "Permissions").extract[List[String]] should equal(consentPermissions) + // v4.0.1 four-letter status codes on the wire (not the stored long name) + StatusReason. + (response.body \ "Data" \ "Status").extract[String] should equal("AWAU") + (response.body \ "Data" \ "StatusReason" \ "StatusReasonCode").extract[List[String]] should equal(List("U036")) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { postUnauthed(consentPostBody, "aisp", "account-access-consents").code should equal(401) @@ -89,6 +92,8 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { response.code should equal(200) (response.body \ "Data" \ "ConsentId").extract[String] should equal(consentId) (response.body \ "Data" \ "Permissions").extract[List[String]] should equal(consentPermissions) + // freshly-created consent is AWAITINGAUTHORISATION → wire code AWAU + (response.body \ "Data" \ "Status").extract[String] should equal("AWAU") } scenario("authenticated with unknown consent -> 400", UKOpenBankingV401AccountInfo) { getAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(400) @@ -107,7 +112,8 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { val afterDelete = getAuthed("aisp", "account-access-consents", consentId) afterDelete.code should equal(200) - (afterDelete.body \ "Data" \ "Status").extract[String] should equal("REVOKED") + // stored status is REVOKED, but the v4.0.1 wire format reports the spec's CANC code + (afterDelete.body \ "Data" \ "Status").extract[String] should equal("CANC") } scenario("authenticated with unknown consent -> 400", UKOpenBankingV401AccountInfo) { deleteAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(400) From 39b9825644e1a7c861992d39761583b3a01e485b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 14:07:43 +0200 Subject: [PATCH 15/63] fix: make UK consent datetime fields optional, fix ISO-8601 parsing ExpirationDateTime/TransactionFromDateTime/TransactionToDateTime are 0..1 per the UK spec's OBReadConsent1 (open-ended if absent), but the shared v3.1.0/v4.0.1 request body class declared them as required Strings -- omitting any one failed json4s extraction. Thread Option[Date] through saveUKConsent/createUKConsentJWT (mirrors the Option[Date] validUntil pattern createBerlinGroupConsentJWT already uses) so a missing ExpirationDateTime means the consent never expires -- represented as Long.MaxValue in the JWT's exp claim, not "now" (which is what the BG sibling function defaults to when its own validUntil is absent; that default is wrong for "no limit" and not copied here). Nothing currently reads this claim for UK consents (checkUKConsent doesn't check expiry yet), so this only sets up correct behaviour for that future gap. Also fix DateWithDayFormat's silent truncation: it's a bare "yyyy-MM-dd" SimpleDateFormat, so a full ISO-8601 datetime like "2020-01-01T00:00:00+00:00" parsed leniently and silently dropped the time and offset, and a malformed value threw an uncaught ParseException that surfaced as 500 instead of 400. Add parseIso8601OrDayDate (tries full ISO-8601 first, falls back to a bare date) and route it through NewStyle.function.tryons so malformed input reaches 400 -- a bare Future.fromTry(Try(...)) doesn't get mapped to 400 by ErrorResponseConverter, since it only preserves the code from APIFailureNewStyle. Both v3.1.0 and v4.0.1 create/get handlers updated (the request body class and parser are shared); GET responses null-guard the now-possibly-absent stored dates instead of stringifying a null Date. --- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 37 ++++++++---- .../JSONFactory_UKOpenBanking_310.scala | 8 ++- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 29 +++++++--- .../JSONFactory_UKOpenBanking_401.scala | 12 ++-- .../main/scala/code/api/util/APIUtil.scala | 15 +++++ .../scala/code/api/util/ConsentUtil.scala | 13 +++-- .../scala/code/consent/ConsentProvider.scala | 6 +- .../scala/code/consent/MappedConsent.scala | 18 +++--- .../UKOpenBankingV401AccountInfoTests.scala | 56 ++++++++++++++++++- 9 files changed, 146 insertions(+), 48 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 03032bc919..36351ec65e 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -5,13 +5,13 @@ import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, DateWithDayFormat} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.CallContext -import code.api.util.{ConsentJWT, JwtUtil} +import code.api.util.{ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents import code.util.Helper.MdcLoggable import com.github.dwickern.macros.NameOf.nameOf @@ -54,6 +54,19 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { consentJson <- Future.fromTry(scala.util.Try( com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) + // Separate step (not inlined into the saveUKConsent call below) so a bad date string + // fails here -> 400. NewStyle.function.tryons (not Future.fromTry) is required for + // that: ErrorResponseConverter only special-cases APIFailureNewStyle to preserve a set + // HTTP code -- tryons wraps failures that way, a bare Future.fromTry(Try(...)) doesn't + // and falls through to unknownErrorToResponse, i.e. 500. + (expirationDateTime, transactionFromDateTime, transactionToDateTime) <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should have valid ISO-8601 ExpirationDateTime/TransactionFromDateTime/TransactionToDateTime values ", 400, Some(cc)) { + ( + consentJson.Data.ExpirationDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionFromDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionToDateTime.map(parseIso8601OrDayDate) + ) + } consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( @@ -62,9 +75,9 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { accountIds = None, consumerId = consumerId, permissions = consentJson.Data.Permissions, - expirationDateTime = DateWithDayFormat.parse(consentJson.Data.ExpirationDateTime), - transactionFromDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionFromDateTime), - transactionToDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionToDateTime), + expirationDateTime = expirationDateTime, + transactionFromDateTime = transactionFromDateTime, + transactionToDateTime = transactionToDateTime, apiStandard = Some("UKOpenBanking"), apiVersion = Some("3.1.0") )) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -83,11 +96,11 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { "Status" : "${createdConsent.status}", "StatusUpdateDateTime" : "${createdConsent.statusUpdateDateTime}", "CreationDateTime" : "${createdConsent.creationDateTime}", - "TransactionToDateTime" : "${consentJson.Data.TransactionToDateTime}", - "ExpirationDateTime" : "${consentJson.Data.ExpirationDateTime}", + "TransactionToDateTime" : "${consentJson.Data.TransactionToDateTime.getOrElse("")}", + "ExpirationDateTime" : "${consentJson.Data.ExpirationDateTime.getOrElse("")}", "Permissions" : ${consentJson.Data.Permissions.mkString("[\"", "\",\"", "\"]")}, "ConsentId" : "${createdConsent.consentId}", - "TransactionFromDateTime" : "${consentJson.Data.TransactionFromDateTime}" + "TransactionFromDateTime" : "${consentJson.Data.TransactionFromDateTime.getOrElse("")}" } }""") } @@ -196,11 +209,11 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { "Status" : "${consent.status}", "StatusUpdateDateTime" : "${consent.statusUpdateDateTime}", "CreationDateTime" : "${consent.creationDateTime}", - "TransactionToDateTime" : "${consent.transactionToDateTime}", - "ExpirationDateTime" : "${consent.expirationDateTime}", + "TransactionToDateTime" : "${Option(consent.transactionToDateTime).getOrElse("")}", + "ExpirationDateTime" : "${Option(consent.expirationDateTime).getOrElse("")}", "Permissions" : ${consentViews.mkString("[\"", "\",\"", "\"]")}, "ConsentId" : "${consent.consentId}", - "TransactionFromDateTime" : "${consent.transactionFromDateTime}" + "TransactionFromDateTime" : "${Option(consent.transactionFromDateTime).getOrElse("")}" } }""") } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala index 396aa2fdfc..ef9c7e986d 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala @@ -204,11 +204,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { Links: LinksV310, Risk: String ) + // The three datetimes are 0..1 (open-ended if absent) per the UK spec's OBReadConsent1 — + // shared by the v3.1.0 and v4.0.1 consent-creation handlers. case class ConsentPostBodyDataUKV310( - TransactionToDateTime: String, - ExpirationDateTime: String, + TransactionToDateTime: Option[String], + ExpirationDateTime: Option[String], Permissions: List[String], - TransactionFromDateTime: String + TransactionFromDateTime: Option[String] ) case class ConsentPostBodyUKV310( Data: ConsentPostBodyDataUKV310, diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 039b31485d..0152026cd9 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -5,11 +5,11 @@ import cats.effect.IO import code.api.APIFailureNewStyle import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, DateWithDayFormat} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle import code.api.util.{APIUtil, ConsentJWT, JwtUtil, NewStyle} @@ -101,6 +101,19 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { consentJson <- Future.fromTry(scala.util.Try( JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) + // Separate step (not inlined into the saveUKConsent call below) so a bad date string + // fails here -> 400. NewStyle.function.tryons (not Future.fromTry) is required for + // that: ErrorResponseConverter only special-cases APIFailureNewStyle to preserve a set + // HTTP code -- tryons wraps failures that way, a bare Future.fromTry(Try(...)) doesn't + // and falls through to unknownErrorToResponse, i.e. 500. + (expirationDateTime, transactionFromDateTime, transactionToDateTime) <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should have valid ISO-8601 ExpirationDateTime/TransactionFromDateTime/TransactionToDateTime values ", 400, Some(cc)) { + ( + consentJson.Data.ExpirationDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionFromDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionToDateTime.map(parseIso8601OrDayDate) + ) + } consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( @@ -109,9 +122,9 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { accountIds = None, consumerId = consumerId, permissions = consentJson.Data.Permissions, - expirationDateTime = DateWithDayFormat.parse(consentJson.Data.ExpirationDateTime), - transactionFromDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionFromDateTime), - transactionToDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionToDateTime), + expirationDateTime = expirationDateTime, + transactionFromDateTime = transactionFromDateTime, + transactionToDateTime = transactionToDateTime, apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") )) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -195,9 +208,9 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { status = consent.status, statusUpdateDateTime = consent.statusUpdateDateTime.toString, permissions = consentViews, - expirationDateTime = consent.expirationDateTime.toString, - transactionFromDateTime = consent.transactionFromDateTime.toString, - transactionToDateTime = consent.transactionToDateTime.toString, + expirationDateTime = Option(consent.expirationDateTime).map(_.toString), + transactionFromDateTime = Option(consent.transactionFromDateTime).map(_.toString), + transactionToDateTime = Option(consent.transactionToDateTime).map(_.toString), selfPath = s"/aisp/account-access-consents/$consentId" ) } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala index 950cac825a..fb2fe5790e 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -143,9 +143,9 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { StatusReason: List[StatusReasonV401], StatusUpdateDateTime: String, Permissions: List[String], - ExpirationDateTime: String, - TransactionFromDateTime: String, - TransactionToDateTime: String + ExpirationDateTime: Option[String], + TransactionFromDateTime: Option[String], + TransactionToDateTime: Option[String] ) case class ConsentResponseV401( Data: ConsentDataV401, @@ -355,9 +355,9 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { status: String, statusUpdateDateTime: String, permissions: List[String], - expirationDateTime: String, - transactionFromDateTime: String, - transactionToDateTime: String, + expirationDateTime: Option[String], + transactionFromDateTime: Option[String], + transactionToDateTime: Option[String], selfPath: String ): ConsentResponseV401 = { val statusCode = ukConsentStatusCode(status) 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 cd283759a1..c899eecb44 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -174,6 +174,21 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def rfc7231Date: SimpleDateFormat = rfc7231DateTL.get() + /** + * Parses a full ISO-8601 datetime with offset (e.g. "2020-01-01T00:00:00+00:00") or, failing + * that, a bare "yyyy-MM-dd" date (midnight UTC). Unlike DateWithDayFormat (a SimpleDateFormat + * with "yyyy-MM-dd"), this does not silently truncate a full datetime's time/offset — it parses + * the whole value or throws java.time.format.DateTimeParseException on genuinely malformed input. + */ + def parseIso8601OrDayDate(s: String): Date = { + try { + Date.from(java.time.OffsetDateTime.parse(s).toInstant) + } catch { + case _: java.time.format.DateTimeParseException => + Date.from(java.time.LocalDate.parse(s).atStartOfDay(java.time.ZoneOffset.UTC).toInstant) + } + } + val DateWithYearExampleString: String = "1100" val DateWithMonthExampleString: String = "1100-01" val DateWithDayExampleString: String = "1100-01-01" 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 94ff5ef545..a51d958704 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1048,9 +1048,9 @@ object Consent extends MdcLoggable { bankId: Option[String], accountIds: Option[List[String]], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], + transactionFromDateTime: Option[Date], + transactionToDateTime: Option[Date], secret: String, consentId: String, consumerId: Option[String] @@ -1059,7 +1059,12 @@ object Consent extends MdcLoggable { val createdByUserId = user.map(_.userId).getOrElse("None") val currentConsumerId = Consumer.findAll(By(Consumer.createdByUserId, createdByUserId)).map(_.consumerId.get).headOption.getOrElse("") val currentTimeInSeconds = System.currentTimeMillis / 1000 - val validUntilTimeInSeconds = expirationDateTime.getTime() / 1000 + // No ExpirationDateTime means the consent never expires (UK spec: 0..1, open-ended if absent). + // Use Long.MaxValue rather than e.g. "now" (the convention createBerlinGroupConsentJWT falls + // back to for its own optional validUntil) since that would make the JWT read as already + // expired -- wrong for "no limit". Nothing currently reads this exp claim for UK consents + // (checkUKConsent doesn't check expiry at all yet), so this only matters once that's added. + val validUntilTimeInSeconds = expirationDateTime.map(_.getTime / 1000).getOrElse(Long.MaxValue) // Write Consent's Auth Context to the DB user map { u => val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) diff --git a/obp-api/src/main/scala/code/consent/ConsentProvider.scala b/obp-api/src/main/scala/code/consent/ConsentProvider.scala index 51b63906d1..3932ed12bc 100644 --- a/obp-api/src/main/scala/code/consent/ConsentProvider.scala +++ b/obp-api/src/main/scala/code/consent/ConsentProvider.scala @@ -48,9 +48,9 @@ trait ConsentProvider { accountIds: Option[List[String]],//for UK Open Banking endpoints, there is no accountIds there. consumerId: Option[String], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], //0..1 per spec: None = open-ended / never expires + transactionFromDateTime: Option[Date], //0..1 per spec: None = no restriction on history start + transactionToDateTime: Option[Date], //0..1 per spec: None = no restriction on history end apiStandard: Option[String], apiVersion: Option[String] ): Box[ConsentTrait] diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 8e0cb38918..960cb731a3 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -267,9 +267,9 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo accountIds: Option[List[String]],//for UK Open Banking endpoints, there is no accountIds there. consumerId: Option[String], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], + transactionFromDateTime: Option[Date], + transactionToDateTime: Option[Date], apiStandard: Option[String], apiVersion: Option[String] ) ={ @@ -279,9 +279,9 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo .mUserId(user.map(_.userId).getOrElse(null)) .mConsumerId(consumerId.getOrElse(null)) .mStatus(ConsentStatus.AWAITINGAUTHORISATION.toString) - .mExpirationDateTime(expirationDateTime) - .mTransactionFromDateTime(transactionFromDateTime) - .mTransactionToDateTime(transactionToDateTime) + .mExpirationDateTime(expirationDateTime.orNull) + .mTransactionFromDateTime(transactionFromDateTime.orNull) + .mTransactionToDateTime(transactionToDateTime.orNull) .mStatusUpdateDateTime(now) .mApiVersion(apiVersion.getOrElse(null)) .mApiStandard(apiStandard.getOrElse(null)) @@ -291,9 +291,9 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo bankId: Option[String], accountIds: Option[List[String]], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], + transactionFromDateTime: Option[Date], + transactionToDateTime: Option[Date], secret = consent.secret, consentId = consent.consentId, consumerId: Option[String] diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 5ab9997c38..257441575c 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -59,9 +59,9 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { accountIds = None, consumerId = None, permissions = consentPermissions, - expirationDateTime = DateWithDayFormat.parse("2030-01-01"), - transactionFromDateTime = DateWithDayFormat.parse("2020-01-01"), - transactionToDateTime = DateWithDayFormat.parse("2030-01-01"), + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") ).openOrThrowException("test consent creation failed") @@ -84,6 +84,56 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { postUnauthed(consentPostBody, "aisp", "account-access-consents").code should equal(401) } + scenario("all three datetime fields omitted -> 201, open-ended (no expiry/date restriction)", UKOpenBankingV401AccountInfo) { + val bodyWithoutDates = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"] + | }, + | "Risk": {} + |}""".stripMargin + val response = postAuthed(bodyWithoutDates, "aisp", "account-access-consents") + response.code should equal(201) + val consentId = (response.body \ "Data" \ "ConsentId").extract[String] + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + // MappedDateTime fields left unset by a null write come back as Java null, not a sentinel date. + consent.expirationDateTime should equal(null) + consent.transactionFromDateTime should equal(null) + consent.transactionToDateTime should equal(null) + } + scenario("full ISO-8601 datetime with time and offset is preserved, not truncated to a bare date", UKOpenBankingV401AccountInfo) { + val bodyWithFullDatetime = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"], + | "ExpirationDateTime": "2030-06-15T13:45:30+02:00", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + val response = postAuthed(bodyWithFullDatetime, "aisp", "account-access-consents") + response.code should equal(201) + val consentId = (response.body \ "Data" \ "ConsentId").extract[String] + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + // 2030-06-15T13:45:30+02:00 == 2030-06-15T11:45:30Z -- if the parser truncated to the bare + // date (the pre-fix DateWithDayFormat behaviour), this would be 2030-06-15T00:00:00Z instead. + consent.expirationDateTime.getTime should equal( + java.time.OffsetDateTime.parse("2030-06-15T13:45:30+02:00").toInstant.toEpochMilli) + } + scenario("malformed datetime -> 400, not 500", UKOpenBankingV401AccountInfo) { + val bodyWithBadDate = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"], + | "ExpirationDateTime": "not-a-date", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + postAuthed(bodyWithBadDate, "aisp", "account-access-consents").code should equal(400) + } } feature("UKOB v4.0.1 GET /aisp/account-access-consents/CONSENT_ID") { scenario("authenticated with real consent -> 200 real data", UKOpenBankingV401AccountInfo) { From 051167e578846c4a95250da15964482fbccbd6e6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 14:24:26 +0200 Subject: [PATCH 16/63] fix: expire UK Open Banking consents past their ExpirationDateTime An AUTHORISED UK consent was effectively perpetual: checkUKConsent only checked not-before (creationDateTime), never the consent's own ExpirationDateTime or the JWT exp, and ConsentScheduler's expiry tasks filtered on apiStandard=BG / apiStandard=obp, excluding UK entirely. A PSU who consented to "until date X" kept granting access indefinitely unless the consent was manually revoked -- a minimum-necessary-access / data-minimisation problem. Add both layers: - Reactive (the actual security control): checkUKConsent now rejects an AUTHORISED consent whose ExpirationDateTime has passed, with the existing OBP-35003 ConsentExpiredIssue (401). A null ExpirationDateTime means the consent never expires (0..1 per spec, open-ended if absent) and is skipped. This closes the gap immediately regardless of scheduler cadence. - Proactive: a new ConsentScheduler.expiredUKConsents task flips long-past-expiry AUTHORISED UK consents to EXPIRED so the stored status stays accurate for GET/dashboard reads. Interval prop uk_open_banking_expired_consents_interval_in_seconds (default 601, 0 to disable), mirroring the existing BG/OBP expiry tasks. A null mExpirationDateTime never matches By_< against a Date, so perpetual consents are correctly never selected. --- .../scala/code/api/util/ConsentUtil.scala | 6 +++ .../code/scheduler/ConsentScheduler.scala | 49 ++++++++++++++++++- .../UKOpenBankingV401AccountInfoTests.scala | 45 +++++++++++++++-- 3 files changed, 96 insertions(+), 4 deletions(-) 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 a51d958704..819f4dc500 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1266,6 +1266,12 @@ object Consent extends MdcLoggable { System.currentTimeMillis match { case currentTimeMillis if currentTimeMillis < c.creationDateTime.getTime => Failure(ErrorMessages.ConsentNotBeforeIssue) + // A null expirationDateTime means the consent never expires (0..1 per spec, + // open-ended if absent -- see createUKConsentJWT). ConsentScheduler.expiredUKConsents + // proactively flips long-past-expiry consents to EXPIRED, but that runs on an + // interval; this reactive check closes the gap immediately regardless of timing. + case currentTimeMillis if Option(c.expirationDateTime).exists(_.getTime < currentTimeMillis) => + Failure(ErrorMessages.ConsentExpiredIssue) case _ if c.mUserId.get != user.userId => Failure(ErrorMessages.ConsentDoesNotMatchUser) case _ => diff --git a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index d44d222899..6f9dd17aa5 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -1,7 +1,7 @@ package code.scheduler import code.api.berlin.group.ConstantsBG -import code.api.util.APIUtil +import code.api.util.{APIUtil, Consent} import code.consent.{ConsentStatus, MappedConsent} import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.{ApiStandards, ApiVersion} @@ -50,6 +50,17 @@ object ConsentScheduler extends MdcLoggable { } else { logger.warn("|---> Skipping expiredObpConsents task: obp_expired_consents_interval_in_seconds set to 0") } + + // UK Open Banking. checkUKConsent (ConsentUtil.scala) also reactively rejects an expired + // AUTHORISED consent on every request regardless of this task's timing -- this proactive + // sweep just keeps the stored status accurate for GET/dashboard purposes. + val ukExpiredInterval = APIUtil.getPropsAsIntValue("uk_open_banking_expired_consents_interval_in_seconds", 601) + if (ukExpiredInterval > 0) { + SchedulerUtil.startTask(interval = ukExpiredInterval, () => expiredUKConsents(), initialDelay) + initialDelay = initialDelay + 10 + } else { + logger.warn("|---> Skipping expiredUKConsents task: uk_open_banking_expired_consents_interval_in_seconds set to 0") + } } @@ -162,5 +173,41 @@ object ConsentScheduler extends MdcLoggable { case Success(_) => logger.debug("|---> Task executed successfully") } } + private def expiredUKConsents(): Unit = { + Try { + logger.debug("|---> Checking for expired UK Open Banking consents...") + + // A null mExpirationDateTime (never set -- 0..1 per spec, open-ended if absent) never + // matches By_< against a real Date, so perpetual consents are correctly never selected here. + val expiredConsents = MappedConsent.findAll( + By(MappedConsent.mStatus, ConsentStatus.AUTHORISED.toString), + By(MappedConsent.mApiStandard, Consent.ConsentStandardUK), + By_<(MappedConsent.mExpirationDateTime, new Date()) + ) + + logger.debug(s"|---> Found ${expiredConsents.size} expired consents") + + expiredConsents.foreach { consent => + Try { + val message = s"|---> Changed status from ${consent.status} to ${ConsentStatus.EXPIRED.toString} for consent ID: ${consent.id}" + val newNote = s"$currentDate\n$message\n" + Option(consent.note).getOrElse("") + val rows = code.bankconnectors.DoobieConsentSchedulerQueries.conditionallyUpdateStatus( + consentPrimaryKey = consent.id.get, + guardStatus = ConsentStatus.AUTHORISED.toString, + newStatus = ConsentStatus.EXPIRED.toString, + newNote = newNote + ) + if (rows > 0) logger.warn(message) + else logger.debug(s"|---> Skipped stale update for UK consent ${consent.id}: status already changed") + } match { + case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) + case Success(_) => // Already logged + } + } + } match { + case Failure(ex) => logger.error("Error in expiredUKConsents!", ex) + case Success(_) => logger.debug("|---> Task executed successfully") + } + } } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 257441575c..57cc8dfcf0 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -2,12 +2,13 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.Constant import code.api.util.APIUtil.DateWithDayFormat -import code.api.util.ErrorMessages.ConsentIdClaimMissing -import code.api.util.Consent -import code.consent.Consents +import code.api.util.ErrorMessages.{ConsentExpiredIssue, ConsentIdClaimMissing} +import code.api.util.{CallContext, CertificateUtil, Consent} +import code.consent.{ConsentStatus, Consents} import code.model.UserExtended import code.views.Views import com.openbankproject.commons.model.{BankIdAccountId, ErrorMessage, ViewId} +import net.liftweb.common.{Failure, Full} import org.json4s._ import org.scalatest.Tag @@ -214,6 +215,44 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } + // Regression coverage for Gap 14 (UK consents previously never expired): checkUKConsent must + // reactively reject an AUTHORISED consent whose ExpirationDateTime has passed, independent of + // ConsentScheduler's proactive sweep timing. Exercised by calling Consent.checkUKConsent + // directly with a hand-built CallContext carrying a self-signed Bearer JWT with a consent_id + // claim -- JwtUtil.getOptionalClaim only parses the JWT structurally (SignedJWT.parse), it does + // not verify the signature, so this doesn't need to be signed with the real shared secret. This + // sidesteps the suite-wide limitation noted above (OAuth1-signed test requests carry no real + // Bearer JWT) for the one scenario that specifically needs one. + feature("UKOB v4.0.1 Consent.checkUKConsent rejects an authorised consent past its ExpirationDateTime") { + scenario("expired consent -> Failure(ConsentExpiredIssue), not silently accepted", UKOpenBankingV401AccountInfo) { + val consent = Consents.consentProvider.vend.saveUKConsent( + user = Some(resourceUser1), + bankId = None, + accountIds = None, + consumerId = None, + permissions = consentPermissions, + expirationDateTime = Some(new java.util.Date(System.currentTimeMillis() - 60000L)), // 1 minute ago + transactionFromDateTime = None, + transactionToDateTime = None, + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("consent creation failed") + Consents.consentProvider.vend.updateConsentUser(consent.consentId, resourceUser1) + Consents.consentProvider.vend.updateConsentStatus(consent.consentId, ConsentStatus.AUTHORISED) + + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .claim("consent_id", consent.consentId) + .build() + val jwt = CertificateUtil.jwtWithHmacProtection(claimsSet) + val callContext = CallContext(authReqHeaderField = Full(s"Bearer $jwt")) + + Consent.checkUKConsent(resourceUser1, Some(callContext)) match { + case Failure(msg, _, _) => msg.contains(ConsentExpiredIssue) should equal(true) + case other => fail(s"expected Failure(ConsentExpiredIssue), got $other") + } + } + } + // ── AccountsApi ──────────────────────────────────────────────────── // checkUKConsent extracts the `consent_id` claim from the Bearer access token (no external // Hydra call since Consent.checkUKConsent dropped the Hydra dependency). These OAuth1-signed From ccba546d793c0f757a0bbacefaa90970b6eddaec Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 14:54:32 +0200 Subject: [PATCH 17/63] feat: allow re-authentication of a UK Open Banking consent authoriseUKConsentChallenge and authoriseUKConsent hard-guarded status == AWAITINGAUTHORISATION, so once a consent was AUTHORISED nothing could reopen it -- a TPP whose access token was lost had to create a brand-new consent. The UK spec allows re-authentication of the same ConsentId while its status is AUTH or CANC (OBP's REVOKED) and its ExpirationDateTime has not elapsed. Relax both endpoints' status guard to ukReAuthableStatuses (AWAITINGAUTHORISATION, AUTHORISED, REVOKED); EXPIRED and REJECTED stay terminal. Add two guards that the previous single-status check made unnecessary but re-auth now requires: - not-expired: reject re-auth of a consent past its ExpirationDateTime (null = never expires), consistent with the reactive expiry check added to checkUKConsent. - same-PSU: a consent already bound to a user may only be re-authorised by that same user, so a different user of the same consumer cannot hijack it (updateConsentUser rebinds mUserId unconditionally). Re-running the SCA + grantUKConsentAccountAccess flow is already idempotent (grantAccessToViews revokes-then-regrants per view), so a second authorise cannot double-grant. Tests cover the two new rejection guards at the challenge-start step. The successful re-auth happy path needs a completed SCA/OTP ceremony, which has no test harness in this repo yet (these authorise endpoints shipped without coverage), so it is not asserted here. --- .../scala/code/api/v5_1_0/Http4s510.scala | 51 +++++++++++++++---- .../UKOpenBankingV401AccountInfoTests.scala | 43 +++++++++++++++- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index e2affd0100..163b638831 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -106,6 +106,17 @@ object Http4s510 { val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + // Statuses from which a UK Open Banking consent may be (re-)authorised. AWAITINGAUTHORISATION + // is the initial authorise; AUTHORISED and REVOKED (wire: CANC) are the re-authentication + // cases per the UK spec ("re-authenticate ... if the account-access-consent has a Status of + // AUTH or CANC and the ExpirationDateTime has not elapsed"). EXPIRED and REJECTED are terminal + // -- the TPP must create a new consent rather than re-authenticate. + private val ukReAuthableStatuses: Set[String] = Set( + ConsentStatus.AWAITINGAUTHORISATION.toString, + ConsentStatus.AUTHORISED.toString, + ConsentStatus.REVOKED.toString + ) + // Used by lifted consumer-management endpoint descriptions. private def consumerDisabledText(): String = { if (APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) == false) { @@ -4283,8 +4294,18 @@ object Http4s510 { user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) - _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to start SCA (current: ${consent.status}).", 400, Some(cc)) { - consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString + _ <- Helper.booleanToFuture(s"$ConsentStatusIssue one of ${ukReAuthableStatuses.mkString(", ")} to start SCA (current: ${consent.status}).", 400, Some(cc)) { + ukReAuthableStatuses.contains(consent.status.toUpperCase) + } + // Re-authentication is only allowed before the consent's ExpirationDateTime (a null + // ExpirationDateTime = never expires); an expired consent must be recreated. + _ <- Helper.booleanToFuture(s"$ConsentExpiredIssue", 400, Some(cc)) { + Option(consent.expirationDateTime).forall(_.getTime >= System.currentTimeMillis) + } + // A consent already bound to a PSU may only be (re-)authorised by that same PSU -- + // otherwise a different user of the same consumer could hijack it. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId } (challenges, _) <- NewStyle.function.createChallengesC2( List(user.userId), @@ -4324,7 +4345,7 @@ object Http4s510 { |""", EmptyBody, UKConsentScaChallengeJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "received", "SMS"), - List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidConnectorResponse, UnknownError), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, ConsentExpiredIssue, ConsentDoesNotMatchUser, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsentChallenge) @@ -4346,11 +4367,23 @@ object Http4s510 { user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) - // Only a consent still awaiting authorisation can be authorised. This status - // is UK-specific (Berlin Group uses "received", OBP uses INITIATED), so the - // guard also effectively scopes this endpoint to the UK flow. - _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to be authorised (current: ${consent.status}).", 400, Some(cc)) { - consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString + // The initial authorisation (AWAITINGAUTHORISATION) and re-authentication of an + // already-authorised or dashboard-revoked (wire: CANC) consent are both allowed -- + // see ukReAuthableStatuses. EXPIRED/REJECTED are terminal. These statuses are + // UK-specific (Berlin Group uses "received", OBP uses INITIATED), so the guard also + // effectively scopes this endpoint to the UK flow. + _ <- Helper.booleanToFuture(s"$ConsentStatusIssue one of ${ukReAuthableStatuses.mkString(", ")} to be authorised (current: ${consent.status}).", 400, Some(cc)) { + ukReAuthableStatuses.contains(consent.status.toUpperCase) + } + // Re-authentication is only allowed before the consent's ExpirationDateTime (a null + // ExpirationDateTime = never expires); an expired consent must be recreated. + _ <- Helper.booleanToFuture(s"$ConsentExpiredIssue", 400, Some(cc)) { + Option(consent.expirationDateTime).forall(_.getTime >= System.currentTimeMillis) + } + // A consent already bound to a PSU may only be (re-)authorised by that same PSU -- + // otherwise a different user of the same consumer could hijack it. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId } // Verify the SCA challenge answer before authorising (dynamic linking to this consent). // The challenge must have been started via POST .../authorise/challenge. @@ -4426,7 +4459,7 @@ object Http4s510 { "eyJhbGciOiJIUzI1NiJ9.eyJ2aWV3cyI6W119.signature", "AUTHORISED" ), - List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidJsonFormat, InvalidChallengeAnswer, $BankAccountNotFound, InvalidConnectorResponse, UnknownError), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, ConsentExpiredIssue, ConsentDoesNotMatchUser, InvalidJsonFormat, InvalidChallengeAnswer, $BankAccountNotFound, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsent) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 57cc8dfcf0..a38f466d4e 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -2,7 +2,7 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.Constant import code.api.util.APIUtil.DateWithDayFormat -import code.api.util.ErrorMessages.{ConsentExpiredIssue, ConsentIdClaimMissing} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, ConsentExpiredIssue, ConsentIdClaimMissing} import code.api.util.{CallContext, CertificateUtil, Consent} import code.consent.{ConsentStatus, Consents} import code.model.UserExtended @@ -253,6 +253,47 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } + // Gap 12 (re-authentication) security guards on POST /obp/v5.1.0/banks/BANK_ID/consents/ + // CONSENT_ID/authorise/challenge -- the first step of the ceremony, so it can be exercised + // without a completed SCA/OTP. The successful re-auth path (start challenge -> answer OTP -> + // stays AUTHORISED) needs the full OTP ceremony, which has no test harness in this repo yet + // (the authorise endpoints shipped with no coverage), so it's not asserted here; these cover + // the security-relevant rejection branches the re-auth relaxation introduces. + private def scaChallengeRequest(consentId: String) = + (baseRequest / "obp" / "v5.1.0" / "banks" / testBankId1.value / "consents" / consentId / "authorise" / "challenge").POST + private def createUKConsent(user: com.openbankproject.commons.model.User, expiration: Option[java.util.Date]): String = { + val consent = Consents.consentProvider.vend.saveUKConsent( + user = Some(user), bankId = None, accountIds = None, consumerId = None, + permissions = consentPermissions, expirationDateTime = expiration, + transactionFromDateTime = None, transactionToDateTime = None, + apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") + ).openOrThrowException("consent creation failed") + consent.consentId + } + feature("UKOB v4.0.1 re-authentication guards on the SCA challenge endpoint") { + scenario("challenge-start on an already-authorised consent bound to a different PSU -> 403", UKOpenBankingV401AccountInfo) { + val consentId = createUKConsent(resourceUser1, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + Consents.consentProvider.vend.updateConsentUser(consentId, resourceUser1) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) + // user2 != the bound resourceUser1 -> hijack guard rejects + val response = makePostRequest(scaChallengeRequest(consentId) <@ (user2), "") + response.code should equal(403) + response.body.extract[ErrorMessage].message.contains(ConsentDoesNotMatchUser) should equal(true) + } + scenario("challenge-start on an authorised consent past its ExpirationDateTime -> 400 ConsentExpiredIssue", UKOpenBankingV401AccountInfo) { + val consentId = createUKConsent(resourceUser1, Some(new java.util.Date(System.currentTimeMillis() - 60000L))) + Consents.consentProvider.vend.updateConsentUser(consentId, resourceUser1) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) + val response = makePostRequest(scaChallengeRequest(consentId) <@ (user1), "") + response.code should equal(400) + response.body.extract[ErrorMessage].message.contains(ConsentExpiredIssue) should equal(true) + } + // Note: the terminal-status rejection (EXPIRED/REJECTED can't be re-authed) is enforced by + // ukReAuthableStatuses not containing those; it isn't asserted via a third HTTP scenario here + // because a second same-user OAuth1 call within this block collides on the test harness's + // nonce/timestamp replay check (a harness artifact -- production UK uses OAuth2 Bearer). + } + // ── AccountsApi ──────────────────────────────────────────────────── // checkUKConsent extracts the `consent_id` claim from the Bearer access token (no external // Hydra call since Consent.checkUKConsent dropped the Hydra dependency). These OAuth1-signed From c6eae4192c3e1f2fdace4e16374a99860979effd Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 15:08:00 +0200 Subject: [PATCH 18/63] fix: allow client-credentials lodging of a UK account-access consent POST /aisp/account-access-consents (both v3.1.0 and v4.0.1) hard-failed with AuthenticatedUserIsRequired whenever no PSU was present, so a TPP could not lodge a consent with a client-credentials (app-only) token -- contradicting the UK spec's Step 2 (the TPP creates the consent as an app; the PSU authorises it later) and OBP's own doc comment on authoriseUKConsent, which describes exactly that client-credentials lodging model. Relax the check to reject only a fully anonymous request (no consumer and no user); a consumer-only context now lodges the consent with no bound PSU (mUserId stays null until authoriseUKConsent binds it after SCA), mirroring the Berlin Group native consent flow. saveUKConsent already takes user: Option[User], so a request that does carry a user (e.g. DirectLogin) is unchanged -- createdByUser just carries it through. The consent row grants nothing until authorised, so this does not widen data access; it only lets the spec-correct app-only lodging step work. The existing "authenticated -> 201" and "unauthenticated -> 401" scenarios still pass (401 now means fully-anonymous rather than no-PSU). The pure consumer-only path can't be exercised via the DirectLogin-based test harness (DirectLogin always binds a user), so it isn't asserted directly here. --- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 13 ++++++++----- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 16 +++++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 36351ec65e..14d2aa0886 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -47,10 +47,13 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext for { - u <- cc.user.toOption match { - case Some(user) => Future.successful(user) - case None => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) - } + // Client-credentials lodging: require some authentication (consumer or user) but not a + // PSU specifically; reject only a fully anonymous request. The PSU is bound later at + // authorise time. Mirrors the Berlin Group native consent flow and the v4.0.1 handler. + _ <- if (cc.user.isEmpty && cc.consumer.isEmpty) + Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + else Future.successful(()) + createdByUser = cc.user.toOption consentJson <- Future.fromTry(scala.util.Try( com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) @@ -70,7 +73,7 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( - Some(u), + createdByUser, bankId = None, accountIds = None, consumerId = consumerId, diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 0152026cd9..a2151997ef 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -94,10 +94,16 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext for { - u <- cc.user.toOption match { - case Some(user) => Future.successful(user) - case None => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) - } + // Spec Step 2: the TPP lodges the consent via a client-credentials grant -- authenticated + // as an app (consumer) but with no PSU yet. Require some authentication (a consumer or a + // user) but not a PSU specifically; reject only a fully anonymous request. The PSU is + // bound later at authorise time (mUserId stays null until then), mirroring the Berlin + // Group native consent flow (Http4sBGv13AIS.createConsent). A request with a real user + // (e.g. DirectLogin) still works -- createdByUser just carries it through. + _ <- if (cc.user.isEmpty && cc.consumer.isEmpty) + Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + else Future.successful(()) + createdByUser = cc.user.toOption consentJson <- Future.fromTry(scala.util.Try( JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) @@ -117,7 +123,7 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( - Some(u), + createdByUser, bankId = None, accountIds = None, consumerId = consumerId, From fc02d403d5d02100f11d7e5c1c9cef7f5bbf4a4f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 15:26:43 +0200 Subject: [PATCH 19/63] feat: emit x-fapi-interaction-id on UK v4.0.1 responses No UK v4.0.1 response carried x-fapi-interaction-id. The only related mechanism was a generic, opt-in, empty-by-default header-mirror prop that echoes a request header verbatim if present -- it never generated one when the TPP omitted it, so interaction tracing didn't work for the common case. (An earlier plan iteration pointed at getHeadersNewStyle for this, but that path is dead Lift-era code never called from the http4s stack.) Wrap the UK v4.0.1 aggregator's routes in an outer middleware that sets x-fapi-interaction-id on every response: echoed from the request when the TPP supplies one, otherwise a freshly generated UUID. Applied outside ResourceDocMiddleware so it also covers error responses. Does not introduce x-fapi-financial-id (correctly absent since v3.x). --- .../UKOpenBanking/v4_0_1/Http4sUKOBv401.scala | 18 ++++++++++++++- .../UKOpenBankingV401AccountInfoTests.scala | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala index 4fe1f01288..c40362d530 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala @@ -2,11 +2,13 @@ package code.api.UKOpenBanking.v4_0_1 import cats.data.{Kleisli, OptionT} import cats.effect._ +import code.api.util.APIUtil import code.api.util.APIUtil.ResourceDoc import code.api.util.http4s.ResourceDocMiddleware import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.ApiVersion import org.http4s._ +import org.typelevel.ci.CIString import scala.collection.mutable.ArrayBuffer @@ -46,5 +48,19 @@ object Http4sUKOBv401 extends MdcLoggable { .orElse(Http4sUKOBv401Vrp.routes(req)) } - val wrappedRoutes: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + private val fapiInteractionIdHeader = CIString("x-fapi-interaction-id") + + // FAPI: every response carries x-fapi-interaction-id -- echoed from the request if the TPP sent + // one (for end-to-end tracing), otherwise generated as a fresh UUID so tracing works even when + // the TPP omits it. Applied as an outer middleware so it covers every UK v4.0.1 response, + // including error responses produced inside ResourceDocMiddleware. + private def withFapiInteractionId(routes: HttpRoutes[IO]): HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + val interactionId = req.headers.get(fapiInteractionIdHeader) + .map(_.head.value) + .getOrElse(APIUtil.generateUUID()) + routes(req).map(_.putHeaders(Header.Raw(fapiInteractionIdHeader, interactionId))) + } + + val wrappedRoutes: HttpRoutes[IO] = withFapiInteractionId(ResourceDocMiddleware.apply(resourceDocs)(allRoutes)) } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index a38f466d4e..cfa4912944 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -294,6 +294,28 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { // nonce/timestamp replay check (a harness artifact -- production UK uses OAuth2 Bearer). } + // Gap 15: every UK v4.0.1 response carries x-fapi-interaction-id (FAPI tracing). Uses a stub + // endpoint that returns 200 so the assertion is purely about the header, independent of consent. + feature("UKOB v4.0.1 x-fapi-interaction-id response header") { + scenario("generated as a UUID when the request omits it", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", "fake-accountid", "beneficiaries") + response.code should equal(200) + val interactionId = response.headers.map(_.get("x-fapi-interaction-id")).orNull + interactionId should not be null + interactionId should not be empty + // a generated value is a UUID + java.util.UUID.fromString(interactionId).toString should equal(interactionId) + } + scenario("echoed verbatim when the request supplies it", UKOpenBankingV401AccountInfo) { + val supplied = "test-interaction-id-12345" + val response = makeGetRequest( + v401("aisp", "accounts", "fake-accountid", "beneficiaries").GET <@ (user1), + List(("x-fapi-interaction-id", supplied))) + response.code should equal(200) + response.headers.map(_.get("x-fapi-interaction-id")).orNull should equal(supplied) + } + } + // ── AccountsApi ──────────────────────────────────────────────────── // checkUKConsent extracts the `consent_id` claim from the Bearer access token (no external // Hydra call since Consent.checkUKConsent dropped the Hydra dependency). These OAuth1-signed From f7b58e8425655d059432c9c57a01784b96995ce7 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 16:50:29 +0200 Subject: [PATCH 20/63] feat: add jwks_uri field to Consumer for OIDC client key storage FAPI 1.0 Advanced needs a place to resolve a client's public key when verifying signed request objects and private_key_jwt client assertions. Adds a jwksUri Mapper field to Consumer (auto-migrates, same pattern as the existing unused clientCertificate field) and exposes it as jwks_uri through both the read-only and admin OIDC consumer views. --- obp-api/src/main/scala/code/model/OAuth.scala | 3 +++ obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql | 1 + obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/model/OAuth.scala b/obp-api/src/main/scala/code/model/OAuth.scala index ed88cb1cf6..a0ebb6e109 100644 --- a/obp-api/src/main/scala/code/model/OAuth.scala +++ b/obp-api/src/main/scala/code/model/OAuth.scala @@ -647,6 +647,9 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{ override def defaultValue : Long = APIUtil.getPropsAsLongValue("rate_limiting_per_month", -1) } object clientCertificate extends MappedString(this, 4000) + // FAPI 1.0 Advanced: URL where this client publishes its JWKS, used to verify + // signed request objects and private_key_jwt client assertions (OBP-OIDC). + object jwksUri extends MappedString(this, 500) object company extends MappedString(this, 100) { override def displayName = "Company:" } diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql index 76bc983f3e..20f2b9a0a0 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql @@ -28,6 +28,7 @@ name ,logourl ,userauthenticationurl ,clientcertificate +,jwksuri ,company ,key_c ,isactive diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql index 4dbfbf1f66..767dc99a1f 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql @@ -25,7 +25,8 @@ SELECT name as client_name, 'code' as response_types, 'client_secret_post' as token_endpoint_auth_method, - createdat as created_at + createdat as created_at, + jwksuri as jwks_uri FROM consumer WHERE isactive = true -- Only expose active consumers to OIDC service ORDER BY client_name; From 2cbc37e5e68b3a2d0a67f86fe92bf81bd6c8c2c5 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 17:46:25 +0200 Subject: [PATCH 21/63] feat: expose client_certificate through the read-only OIDC clients view FAPI 1.0 Advanced's tls_client_auth needs the client's registered certificate at token-request time, not just in the admin view. The Consumer.clientCertificate field already exists (unused); this just adds it to v_oidc_clients alongside the existing jwks_uri column. --- obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql index 767dc99a1f..aa0c2bd842 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql @@ -26,7 +26,8 @@ SELECT 'code' as response_types, 'client_secret_post' as token_endpoint_auth_method, createdat as created_at, - jwksuri as jwks_uri + jwksuri as jwks_uri, + clientcertificate as client_certificate FROM consumer WHERE isactive = true -- Only expose active consumers to OIDC service ORDER BY client_name; From 5b05ece2a98d8eeb4a8635253216c4ab8b5e1d0c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 21 Jul 2026 09:55:28 +0200 Subject: [PATCH 22/63] fix: wrap UK consent test dates in Option after merging upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge from origin/develop pulled in createConsentWithStandard, which called saveUKConsent with raw java.util.Date arguments. This session's earlier work changed those three params to Option[Date], so the merge produced a type mismatch that git's line-based merge couldn't detect on its own — it only surfaces at compile time. --- .../v4_0_1/UKOpenBankingV401AccountInfoTests.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index e56d1048a0..66e87a55ff 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -78,9 +78,9 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { accountIds = None, consumerId = None, permissions = consentPermissions, - expirationDateTime = DateWithDayFormat.parse("2030-01-01"), - transactionFromDateTime = DateWithDayFormat.parse("2020-01-01"), - transactionToDateTime = DateWithDayFormat.parse("2030-01-01"), + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), apiStandard = standard, apiVersion = Some("4.0.1") ).openOrThrowException("test consent creation failed").consentId From 3b8445398afe1b5b9114f7d9ac397480e8afac66 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 21 Jul 2026 23:33:30 +0200 Subject: [PATCH 23/63] fix: reject UK consent authorisation naming an account the PSU does not hold grantUKConsentAccountAccess only verified that the requested account_ids exist (checkBankAccountExists), never that the PSU authorising the consent actually holds them. Any authenticated user could therefore authorise a UK consent naming an arbitrary existing account_id and be granted every consented view on it. Add an ownership check against AccountHolders.getAccountsHeld before binding the consent's views to the requested accounts; reject with a new OBP-35037 error when any requested account is not held by the current user. Add a regression test: resourceUser2 attempting to authorise a consent naming resourceUser1's account is rejected and gains no AccountAccess. --- .../scala/code/api/util/ConsentUtil.scala | 12 +++++- .../scala/code/api/util/ErrorMessages.scala | 1 + .../UKOpenBankingV401AccountInfoTests.scala | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) 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 8026022716..dae37e42a1 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1154,12 +1154,22 @@ object Consent extends MdcLoggable { val error = s"$BankAccountNotFound BankId(${bankId.value})" val validatedAccountIds: List[String] = boxes.map(_.openOrThrowException(error)).map(_.accountId.value) + // Existence alone is not enough: only bind the consent to accounts the authorising PSU + // actually holds -- otherwise any authenticated user could authorise a consent naming an + // arbitrary existing account_id and be granted every consented view on it. + val heldAccountIds: Set[String] = AccountHolders.accountHolders.vend + .getAccountsHeld(bankId, user) + .map(_.accountId.value) + val notHeld: List[String] = validatedAccountIds.filterNot(heldAccountIds.contains) + val newViews: List[ConsentView] = for { accountId <- validatedAccountIds permission <- permissions } yield ConsentView(bank_id = bankId.value, account_id = accountId, view_id = permission, None) - if (newViews.isEmpty) { + if (notHeld.nonEmpty) { + Failure(s"$ConsentAccountNotHeldByUser Account(s): ${notHeld.mkString(", ")}") + } else if (newViews.isEmpty) { Empty } else { val updatedPayload = payloadToUpdate.map(_.copy(views = newViews)) 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 b11cf0fbb6..453b227666 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -770,6 +770,7 @@ object ErrorMessages { val UserAuthContextUpdateRequestAllowedScaMethods = "OBP-35034: Unsupported as SCA method. " val ConsentIdClaimMissing = "OBP-35035: The access token is not bound to a Consent. The identity provider must include a consent_id claim in access tokens issued via the consent authorisation flow. " val ConsentDoesNotMatchStandard = "OBP-35036: The Consent was created by a different API standard than the endpoint using it. A consent may only be used by endpoints of the standard that created it. " + val ConsentAccountNotHeldByUser = "OBP-35037: One or more of the specified account_ids is not held by the current user. A consent may only be authorised for accounts the authorising user holds. " //Authorisations val AuthorisationNotFound = "OBP-36001: Authorisation not found. Please specify valid values for PAYMENT_ID and AUTHORISATION_ID. " diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 66e87a55ff..c7b60080ba 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -253,6 +253,43 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } + // ── Consent.grantUKConsentAccountAccess must reject accounts the PSU does not hold ── + // Regression test for the account-ownership gap: grantUKConsentAccountAccess only verified + // that the requested account_ids *exist* (checkBankAccountExists), never that the PSU + // authorising the consent actually holds them. Without this check, any authenticated user + // could authorise a UK consent naming ANY existing account_id (e.g. testAccountId1, held by + // resourceUser1) and be granted every consented view on it -- an account-access IDOR. This + // mirrors the existing "binds permissions to the selected account only" scenario above but + // asserts on account holder-ship (AccountHolders.getAccountsHeld) rather than permission scope. + feature("UKOB v4.0.1 Consent.grantUKConsentAccountAccess rejects an account the PSU does not hold") { + scenario("resourceUser2 tries to authorise a consent naming resourceUser1's account -> rejected, no access granted", UKOpenBankingV401AccountInfo) { + val userExtended = UserExtended(resourceUser2) + val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + + // testAccountId1 is held by resourceUser1 (ServerSetupWithTestData.beforeEach), not resourceUser2. + code.accountholders.AccountHolders.accountHolders.vend + .getAccountsHeld(testBankId1, resourceUser2) + .contains(bankIdAccountId) should equal(false) + + val consentId = createRealConsent() // permissions = List("ReadAccountsBasic") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + + val result = Await.result( + Consent.grantUKConsentAccountAccess(resourceUser2, testBankId1, List(acc), consent, None), + 10.seconds) + result.isDefined should equal(false) + result match { + case Failure(msg, _, _) => msg should include("OBP-35037") + case other => fail(s"expected Failure(OBP-35037...), got $other") + } + + // No AccountAccess row must have been created for resourceUser2 on this account. + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + } + } + // Regression coverage for Gap 14 (UK consents previously never expired): checkUKConsent must // reactively reject an AUTHORISED consent whose ExpirationDateTime has passed, independent of // ConsentScheduler's proactive sweep timing. Exercised by calling Consent.checkUKConsent From e85f4256585e9823d6329dd319194878e2cc98b8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 22 Jul 2026 09:02:20 +0200 Subject: [PATCH 24/63] fix: skip unresolvable accounts in getCoreBankAccountsLegacy A single stale AccountHolder grant pointing at a deleted account made getCoreBankAccountsLegacy throw and 500 the entire "list my accounts" call, because it unconditionally opened each resolved account with openOrThrowException. Mirror the same tolerance already applied to the sibling method getBankAccounts: skip accounts that can't be resolved instead of failing the whole batch. --- .../scala/code/bankconnectors/LocalMappedConnector.scala | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 5fcc5adfcb..b5854304f0 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1197,13 +1197,16 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getCoreBankAccountsLegacy(bankIdAccountIds: List[BankIdAccountId], callContext: Option[CallContext]): Box[(List[CoreAccount], Option[CallContext])] = { Full( bankIdAccountIds - .map(bankIdAccountId => + // Tolerate stale account access: a user can hold a view/grant on an account that has + // since been deleted (a dangling BankIdAccountId). Skip such accounts instead of + // throwing, which would otherwise 500 an entire "list my accounts" call because of one + // orphaned grant. Callers that need a specific account use getBankAccount(single). + .flatMap(bankIdAccountId => getBankAccountLegacy( bankIdAccountId.bankId, bankIdAccountId.accountId, callContext - ).map(_._1) - .openOrThrowException(s"${ErrorMessages.BankAccountNotFound} current BANK_ID(${bankIdAccountId.bankId}) and ACCOUNT_ID(${bankIdAccountId.accountId})")) + ).map(_._1).toList) .map(account => CoreAccount( account.accountId.value, From def2da7922ecf165e5a198b7cb2fbc55809fba0d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 22 Jul 2026 09:02:40 +0200 Subject: [PATCH 25/63] fix: don't bind a UK consent to the lodging consumer's own pseudo-user A client-credentials token still resolves cc.user to an auto-vivified pseudo-user (idGivenByProvider equal to the calling consumer's own client key) rather than leaving it Empty. Both UK consent creation endpoints (v3.1.0 and v4.0.1) carried that value straight through into saveUKConsent's user param, permanently binding the consent to the TPP's own pseudo-identity instead of leaving it unowned as intended. Since authoriseUKConsent/authoriseUKConsentChallenge reject authorisation unless the consent's userId is blank or matches the authorising user, a consent lodged this way could never be authorised by the real PSU -- every attempt failed with ConsentDoesNotMatchUser. Filter out that pseudo-user before passing it to saveUKConsent, so the consent stays unowned until the PSU actually authorises it. --- .../UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala | 6 ++++++ .../UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 14d2aa0886..e70a592d8b 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -53,7 +53,13 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { _ <- if (cc.user.isEmpty && cc.consumer.isEmpty) Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) else Future.successful(()) + // A pure client-credentials token still resolves cc.user to an auto-vivified + // pseudo-user (idGivenByProvider == the calling consumer's own client key) rather than + // leaving it Empty -- that pseudo-user is not a PSU, so it must not become the + // consent's owner (it would permanently block the real PSU's authorise-time + // ConsentDoesNotMatchUser check). Only carry a genuine PSU session through. createdByUser = cc.user.toOption + .filterNot(u => cc.consumer.map(_.key.get).contains(u.idGivenByProvider)) consentJson <- Future.fromTry(scala.util.Try( com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index a2151997ef..0258b478e6 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -103,7 +103,13 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { _ <- if (cc.user.isEmpty && cc.consumer.isEmpty) Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) else Future.successful(()) + // A pure client-credentials token still resolves cc.user to an auto-vivified + // pseudo-user (idGivenByProvider == the calling consumer's own client key) rather than + // leaving it Empty -- that pseudo-user is not a PSU, so it must not become the + // consent's owner (it would permanently block the real PSU's authorise-time + // ConsentDoesNotMatchUser check). Only carry a genuine PSU session through. createdByUser = cc.user.toOption + .filterNot(u => cc.consumer.map(_.key.get).contains(u.idGivenByProvider)) consentJson <- Future.fromTry(scala.util.Try( JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) From cdee4865a78ae00faca906a1661e797b1a4ec31a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 00:32:24 +0200 Subject: [PATCH 26/63] fix: apply the UK consent gate to the remaining v3.1.0 AIS endpoints Three v3.1.0 endpoints reached real account data without ever calling checkUKConsent, while their v4.0.1 counterparts did: - GET /accounts/ACCOUNT_ID (Http4sUKOBv310Accounts) - GET /balances (Http4sUKOBv310Balances) - GET /transactions (Http4sUKOBv310Transactions) None of them was an access-control hole on its own -- each is self-scoping via getPrivateBankAccountsFuture / checkOwnerViewAccessAndReturnOwnerView, so a caller only ever saw their own accounts. What was missing is the consent-lifecycle gate every other AISP data endpoint applies: a token with no bound consent, or one whose consent is expired or revoked, still got data instead of the 403 the rest of the surface returns. Add checkUKConsent + passesPsd2Aisp to all three, matching the ordering already used by their v4.0.1 twins. The stale "no consent check (mirrors Lift)" comments on getBalances/getTransactions are updated accordingly. --- .../api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala | 2 ++ .../api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala | 5 ++++- .../UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala index 5065276be6..c40f63c8c0 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala @@ -107,6 +107,8 @@ object Http4sUKOBv310Accounts extends MdcLoggable { val detailViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) val basicViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) map { _.filter(_.accountId.value == accountId.value) } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala index 5d9b95801a..1fb4eeae35 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala @@ -28,7 +28,8 @@ import scala.collection.mutable.ArrayBuffer /** * UK Open Banking v3.1 — BalancesApi, migrated from Lift to http4s. * getAccountsAccountIdBalances: real business logic with UK consent check. - * getBalances: real business logic, no consent check (mirrors Lift). + * getBalances: real business logic, UK consent check (kept consistent with the v4.0.1 + * equivalent -- see Http4sUKOBv401AccountInfo.getBalances). */ object Http4sUKOBv310Balances extends MdcLoggable { type HttpF[A] = OptionT[IO, A] @@ -136,6 +137,8 @@ object Http4sUKOBv310Balances extends MdcLoggable { case req @ GET -> `ukV31Prefix` / "balances" => EndpointHelpers.withUser(req) { (u, cc) => for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) } yield JSONFactory_UKOpenBanking_310.createBalancesJSON(accounts) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala index 682cc3cce9..6b83e17911 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala @@ -37,7 +37,8 @@ import scala.concurrent.Future * Http4sUKOBv310Statements (which takes precedence in the Lift priority order). * This file owns: * - getAccountsAccountIdTransactions: real business logic with UK consent check. - * - getTransactions: real business logic, bulk (no consent check, mirrors Lift). + * - getTransactions: real business logic, bulk, UK consent check (kept consistent with + * the v4.0.1 equivalent -- see Http4sUKOBv401AccountInfo.getTransactions). */ object Http4sUKOBv310Transactions extends MdcLoggable { type HttpF[A] = OptionT[IO, A] @@ -574,6 +575,8 @@ object Http4sUKOBv310Transactions extends MdcLoggable { case req @ GET -> `ukV31Prefix` / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) (bank, _) <- NewStyle.function.getBank(BankId(defaultBankId), Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) From 865bcdf54911b4e69ec445a3410f5944b334ab9d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 00:32:47 +0200 Subject: [PATCH 27/63] fix: restrict UK account-access-consent reads and revocations to their owner GET and DELETE /account-access-consents/CONSENT_ID (v3.1.0 and v4.0.1) only checked that the consent existed. Neither verified who was asking, so any authenticated caller could read any consent's details, or -- far worse -- revoke it, just by knowing or guessing its consentId. DELETE went straight from getConsentByConsentId to revoke() with nothing in between. Both endpoints now enforce the identity contract already applied at consent authorise time (Http4s510: consent.userId == user.userId): a consent bound to a PSU may only be read or revoked by that PSU, else 403 ConsentDoesNotMatchUser. That check deliberately lets an unbound (still pending) consent through, which left a narrower cross-TPP gap: before any PSU authorises it, a second Consumer could still read or revoke the first Consumer's pending consent. Add a companion check for exactly that window -- while consent.userId is blank, the calling Consumer must match the one that lodged it, else 403 ConsentDoesNotMatchConsumer (the existing OBP-35015; no new error code). Once a PSU is bound the consumer check short-circuits and the user check governs, so the normal lifecycle is unaffected. Also add the consent gate to v4.0.1 GET /aisp/accounts/ACCOUNT_ID, which returned real account data without calling checkUKConsent while its sibling /balances and /transactions endpoints did. Regression tests cover all four rejection paths across both versions (cross-user and cross-consumer, GET and DELETE), and assert the rejected DELETE leaves the consent's stored status untouched. Verified red before the fix and green after: 135/135 across both UK suites. Document in CLAUDE.md why UK v2.0.0 is intentionally left without a consent gate: it is self-scoping, and the v2.0.0 standard defines no consent resource to gate against. --- CLAUDE.md | 2 + .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 38 +++++++- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 40 +++++++- .../v3_1_0/UKOpenBankingV310AisTests.scala | 61 +++++++++++- .../v3_1_0/UKOpenBankingV310ServerSetup.scala | 7 ++ .../UKOpenBankingV401AccountInfoTests.scala | 94 ++++++++++++++++--- .../v4_0_1/UKOpenBankingV401ServerSetup.scala | 4 + 7 files changed, 222 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ebe0882cb..10af089e29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -265,6 +265,8 @@ Symptoms in tests: a v4-specific assertion fails (e.g. an entitlement should-be- **`isStatisticallyTooPermissive` is sample-pool-dependent**: a fresh local test DB with a single user trips the ABAC-permissiveness check and causes spurious rejections. Seed enough users in any test exercising ABAC rules — it's a test-data issue, not a regression. +**UK Open Banking v2.0.0 has no consent concept — intentionally left as-is**: unlike v3.1.0 and v4.0.1, the five real endpoints in `Http4sUKOBv200AIS.scala` (`/accounts`, `/accounts/ACCOUNT_ID`, `/balances`, `/accounts/ACCOUNT_ID/balances`, `/accounts/ACCOUNT_ID/transactions`) never call `checkUKConsent`, and the v2.0.0 standard defines no `account-access-consents` resource to create one against. This is **not** a data-exposure gap: every one of those handlers is self-scoping via `Views.views.vend.getPrivateBankAccountsFuture(u)` and/or `ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(u, ...)`, so a caller only ever reaches their own accounts. Adding a consent gate here would mean inventing a resource the spec doesn't have, or borrowing v3.1/v4.0.1's consent store and thereby breaking the standard-boundary rule enforced by `assertConsentStandard`. Leave it alone; if a consent-scoped UK integration is needed, point it at v3.1.0 or v4.0.1. + ## CI (shard map + run tips) Perf note: integration tests are DB/HTTP-bound (~0.4 s/test) on both frameworks; the http4s win is the **pure-unit tier** (no running server, ~0.008 s/test). `ResourceDocsTest`/`SwaggerDocsTest` are the slowest per-test cost — they serialize the whole API surface, so cost grows with endpoint count. `Http4sResourceDocs` already caches the serialized output (`Caching.{getDynamic,getStatic,getAll}ResourceDocCache` + `getStaticSwaggerDocCache`, keyed via `APIUtil.createResourceDocCacheKey`), so repeat requests for the same version/params skip re-serialization. diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index e70a592d8b..e5af52447b 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -8,11 +8,12 @@ import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBo import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.CallContext import code.api.util.{ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents +import code.util.Helper import code.util.Helper.MdcLoggable import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -165,12 +166,27 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV31Prefix` / "account-access-consents" / consentId => - EndpointHelpers.withUserDelete(req) { (_, cc) => + EndpointHelpers.withUserDelete(req) { (user, cc) => for { _ <- passesPsd2Aisp(Some(cc)) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } + // A consent already bound to a PSU may only be revoked by that same PSU -- otherwise any + // authenticated party could revoke another party's consent (IDOR, and the most severe of + // the two since it's destructive). Mirrors the identity contract enforced at consent + // authorise time (Http4s510: consent.userId == user.userId). + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId + } + // A consent not yet bound to any PSU may only be revoked by the Consumer that created + // it -- otherwise a second TPP could revoke a first TPP's still-pending consent. Once a + // PSU is bound the check above already governs, so this is a no-op then. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { + !Option(consent.userId).forall(_.isBlank) || + Option(consent.consumerId).forall(_.isBlank) || + cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + } _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -195,11 +211,25 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV31Prefix` / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (_, cc) => + EndpointHelpers.withUser(req) { (user, cc) => for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } + // A consent already bound to a PSU may only be read by that same PSU -- otherwise any + // authenticated party could read another party's consent details (IDOR). Mirrors the + // identity contract enforced at consent authorise time (Http4s510: consent.userId == user.userId). + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId + } + // A consent not yet bound to any PSU may only be read by the Consumer that created it -- + // otherwise a second TPP could read a first TPP's still-pending consent by guessing its + // consentId. Once a PSU is bound the check above already governs, so this is a no-op then. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { + !Option(consent.userId).forall(_.isBlank) || + Option(consent.consumerId).forall(_.isBlank) || + cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + } consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 0258b478e6..52f97ed02b 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -9,12 +9,13 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyR import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle import code.api.util.{APIUtil, ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents import code.model.{BankAccountExtended, UserExtended} +import code.util.Helper import code.util.Helper.MdcLoggable import code.views.Views import com.github.dwickern.macros.NameOf.nameOf @@ -205,11 +206,25 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (_, cc) => + EndpointHelpers.withUser(req) { (user, cc) => for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } + // A consent already bound to a PSU may only be read by that same PSU -- otherwise any + // authenticated party could read another party's consent details (IDOR). Mirrors the + // identity contract enforced at consent authorise time (Http4s510: consent.userId == user.userId). + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId + } + // A consent not yet bound to any PSU may only be read by the Consumer that created it -- + // otherwise a second TPP could read a first TPP's still-pending consent by guessing its + // consentId. Once a PSU is bound the check above already governs, so this is a no-op then. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { + !Option(consent.userId).forall(_.isBlank) || + Option(consent.consumerId).forall(_.isBlank) || + cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + } consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } @@ -245,12 +260,27 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { private val EX_deleteAccountAccessConsentsConsentId: String = """{}""" lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUserDelete(req) { (_, cc) => + EndpointHelpers.withUserDelete(req) { (user, cc) => for { _ <- passesPsd2Aisp(Some(cc)) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } + // A consent already bound to a PSU may only be revoked by that same PSU -- otherwise any + // authenticated party could revoke another party's consent (IDOR, and the most severe of + // the two since it's destructive). Mirrors the identity contract enforced at consent + // authorise time (Http4s510: consent.userId == user.userId). + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId + } + // A consent not yet bound to any PSU may only be revoked by the Consumer that created + // it -- otherwise a second TPP could revoke a first TPP's still-pending consent. Once a + // PSU is bound the check above already governs, so this is a no-op then. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { + !Option(consent.userId).forall(_.isBlank) || + Option(consent.consumerId).forall(_.isBlank) || + cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + } _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -460,6 +490,8 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { val detailViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) val basicViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) map { _.filter(_.accountId.value == accountId.value) } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala index e369ca5706..b607a6ddae 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala @@ -1,5 +1,9 @@ package code.api.UKOpenBanking.v3_1_0 +import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.ErrorMessages.ConsentDoesNotMatchConsumer +import code.consent.Consents +import com.openbankproject.commons.model.ErrorMessage import org.scalatest.Tag /** @@ -20,6 +24,24 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { val acc = testAccountId1.value + // A pending (not yet authorised -- no bound PSU) consent, created by "consumer" (the OAuth1 + // consumer backing user1). For the cross-consumer regression tests: user2 authenticates with a + // *different* consumer (consumer2, see DefaultUsers), so this simulates a second TPP trying to + // reach a first TPP's still-pending consent before any PSU has authorised it. + private def createPendingConsentForConsumer1(): String = + Consents.consentProvider.vend.saveUKConsent( + user = None, + bankId = None, + accountIds = None, + consumerId = Some(testConsumer.consumerId.get), + permissions = List("ReadAccountsBasic"), + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("3.1.0") + ).openOrThrowException("test consent creation failed").consentId + // ── AccountAccessApi ─────────────────────────────────────────────── feature("UKOB v3.1 POST /account-access-consents") { scenario("authenticated", UKOpenBankingV310) { @@ -38,6 +60,21 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV310) { deleteUnauthed("account-access-consents", "fake-consent-id").code should equal(401) } + // Cross-consumer regression (currently RED): a pending consent (no bound PSU yet) is + // currently revokable by ANY authenticated party -- the ownership check added to + // Http4sUKOBv310AccountAccess.deleteAccountAccessConsentsConsentId only guards against a + // *different bound user*, not a *different consumer*. deleteAuthedAsUser2 authenticates as + // consumer2 (see DefaultUsers), a different OAuth1 consumer than the one that created this + // pending consent (testConsumer/consumer). Once fixed, this must be 403 + // ConsentDoesNotMatchConsumer, and the consent must be left untouched. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, and consent is left untouched", UKOpenBankingV310) { + val consentId = createPendingConsentForConsumer1() + val response = deleteAuthedAsUser2("account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + + Consents.consentProvider.vend.getConsentByConsentId(consentId).isDefined should equal(true) + } } feature("UKOB v3.1 GET /account-access-consents/CONSENT_ID") { scenario("authenticated", UKOpenBankingV310) { @@ -47,6 +84,16 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV310) { getUnauthed("account-access-consents", "fake-consent-id").code should equal(401) } + // Cross-consumer regression (currently RED): same root cause as the DELETE gap above -- + // a pending consent is currently readable by ANY authenticated party. getAuthedAsUser2 + // authenticates as consumer2, a different OAuth1 consumer than the one that created this + // pending consent. Once fixed, this must be 403 ConsentDoesNotMatchConsumer. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, not 200", UKOpenBankingV310) { + val consentId = createPendingConsentForConsumer1() + val response = getAuthedAsUser2("account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + } } // ── AccountsApi ──────────────────────────────────────────────────── @@ -80,8 +127,11 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { } } feature("UKOB v3.1 GET /balances") { - scenario("authenticated -> 200", UKOpenBankingV310) { - getAuthed("balances").code should equal(200) + scenario("authenticated", UKOpenBankingV310) { + // DATA-DEPENDENT: checkUKConsent + passesPsd2Aisp (Issue B fix -- kept consistent + // with v4.0.1's getBalances, see UKOpenBankingV401AccountInfoTests). This OAuth1-signed + // test request carries no Bearer JWT, so checkUKConsent deterministically 403s here. + getAuthed("balances").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV310) { getUnauthed("balances").code should equal(401) @@ -270,8 +320,11 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { } } feature("UKOB v3.1 GET /transactions") { - scenario("authenticated -> 200", UKOpenBankingV310) { - getAuthed("transactions").code should equal(200) + scenario("authenticated", UKOpenBankingV310) { + // DATA-DEPENDENT: checkUKConsent + passesPsd2Aisp (Issue B fix -- kept consistent + // with v4.0.1's getTransactions, see UKOpenBankingV401AccountInfoTests). This OAuth1-signed + // test request carries no Bearer JWT, so checkUKConsent deterministically 403s here. + getAuthed("transactions").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV310) { getUnauthed("transactions").code should equal(401) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala index 51f81712ed..7bfbb6fee9 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala @@ -22,9 +22,16 @@ trait UKOpenBankingV310ServerSetup extends ServerSetupWithTestData with DefaultU def getAuthed(segments: String*): APIResponse = makeGetRequest(v31(segments: _*).GET <@ (user1)) def getUnauthed(segments: String*): APIResponse = makeGetRequest(v31(segments: _*).GET) + // For IDOR regression tests: user2 authenticated (a different user AND a different OAuth1 + // consumer than user1, see DefaultUsers), acting on a resource (e.g. a consent) that belongs + // to a different user/consumer. + def getAuthedAsUser2(segments: String*): APIResponse = makeGetRequest(v31(segments: _*).GET <@ (user2)) + def postAuthed(body: String, segments: String*): APIResponse = makePostRequest(v31(segments: _*).POST <@ (user1), body) def postUnauthed(body: String, segments: String*): APIResponse = makePostRequest(v31(segments: _*).POST, body) def deleteAuthed(segments: String*): APIResponse = makeDeleteRequest(v31(segments: _*).DELETE <@ (user1)) def deleteUnauthed(segments: String*): APIResponse = makeDeleteRequest(v31(segments: _*).DELETE) + + def deleteAuthedAsUser2(segments: String*): APIResponse = makeDeleteRequest(v31(segments: _*).DELETE <@ (user2)) } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index c7b60080ba..8a65af1972 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -2,7 +2,7 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.Constant import code.api.util.APIUtil.DateWithDayFormat -import code.api.util.ErrorMessages.{ConsentDoesNotMatchUser, ConsentExpiredIssue, ConsentIdClaimMissing} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentExpiredIssue, ConsentIdClaimMissing} import code.api.util.{CallContext, CertificateUtil, Consent} import code.consent.{ConsentStatus, Consents} import code.model.UserExtended @@ -85,6 +85,24 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { apiVersion = Some("4.0.1") ).openOrThrowException("test consent creation failed").consentId + // A pending (not yet authorised -- no bound PSU) consent, created by "consumer" (the OAuth1 + // consumer backing user1). For the cross-consumer regression tests: user2 authenticates with a + // *different* consumer (consumer2, see DefaultUsers), so this simulates a second TPP trying to + // reach a first TPP's still-pending consent before any PSU has authorised it. + private def createPendingConsentForConsumer1(): String = + Consents.consentProvider.vend.saveUKConsent( + user = None, + bankId = None, + accountIds = None, + consumerId = Some(testConsumer.consumerId.get), + permissions = consentPermissions, + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("test consent creation failed").consentId + // ── Cross-standard exercise boundary (ConsentUtil.assertConsentStandard) ── feature("A consent may only be exercised by the standard that created it") { import code.api.util.Consent @@ -190,6 +208,31 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } + // IDOR regression (currently RED): the endpoint only checks that the consent exists, never + // that the caller owns it (see Http4sUKOBv401AccountInfo.getAccountAccessConsentsConsentId, + // which discards the resolved user via `(_, cc)` before the lookup). Any authenticated party + // can currently read any other party's consent details -- this must become a 403 + // ConsentDoesNotMatchUser once the ownership check is added, mirroring the identity contract + // already enforced at consent authorise time (Http4s510: consent.userId == user.userId). + scenario("authenticated as a different user than the consent owner -> 403, not 200", UKOpenBankingV401AccountInfo) { + val consentId = createRealConsent() // owned by resourceUser1 + val response = getAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchUser) + } + // Cross-consumer regression (currently RED): a pending consent (no bound PSU yet) is + // currently readable by ANY authenticated party, because the ownership check + // (Option(consent.userId).forall(_.isBlank) || ...) deliberately lets pending consents + // through -- it only guards against a *different bound user*, not a *different consumer*. + // getAuthedAsUser2 authenticates as consumer2 (see DefaultUsers), a different OAuth1 + // consumer than the one that created this pending consent (testConsumer/consumer). Once + // fixed, a different consumer must get 403 ConsentDoesNotMatchConsumer here. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, not 200", UKOpenBankingV401AccountInfo) { + val consentId = createPendingConsentForConsumer1() + val response = getAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + } } feature("UKOB v4.0.1 DELETE /aisp/account-access-consents/CONSENT_ID") { scenario("full consent lifecycle: create -> get -> delete -> get", UKOpenBankingV401AccountInfo) { @@ -210,6 +253,36 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { deleteUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } + // IDOR regression (currently RED, most severe of the two): deleteAccountAccessConsentsConsentId + // (Http4sUKOBv401AccountInfo) resolves the consent by id and calls + // Consents.consentProvider.vend.revoke(consentId) directly -- no ownership check at all. Any + // authenticated party can currently revoke any other party's consent. Once fixed this must be + // a 403 ConsentDoesNotMatchUser, and -- critically -- the consent's stored status must be left + // untouched (still AWAITINGAUTHORISATION), proving the rejected delete had zero side effect. + scenario("authenticated as a different user than the consent owner -> 403, and consent is left untouched", UKOpenBankingV401AccountInfo) { + val consentId = createRealConsent() // owned by resourceUser1 + val response = deleteAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchUser) + + val stillThere = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + stillThere.status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + // Cross-consumer regression (currently RED, most severe of this pair): a pending consent + // (no bound PSU yet) is currently revokable by ANY authenticated party -- same root cause + // as the GET cross-consumer gap above (the ownership check treats "no bound user yet" as + // "anyone may proceed"). getAuthedAsUser2/deleteAuthedAsUser2 authenticate as consumer2, a + // different OAuth1 consumer than the one that created this pending consent. Once fixed, + // this must be 403 ConsentDoesNotMatchConsumer, and the consent must be left untouched. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, and consent is left untouched", UKOpenBankingV401AccountInfo) { + val consentId = createPendingConsentForConsumer1() + val response = deleteAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + + val stillThere = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + stillThere.status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } } // ── Consent.grantUKConsentAccountAccess (Gap 4 fix) ─────────────────── // Regression test for the previously-unverified scenario: before this fix, @@ -407,19 +480,16 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID") { - scenario("authenticated with granted read view -> 200 real account data", UKOpenBankingV401AccountInfo) { + // Issue A fix: this endpoint now runs checkUKConsent before the account lookup, matching + // its sibling /balances and /transactions endpoints below. This OAuth1-signed test suite + // carries no Bearer JWT, so the consent check deterministically 403s here -- the previous + // "200 real account data" scenarios (dropped) actually reached real data with zero consent + // enforcement, which was Issue A itself. + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { grantUKReadViews(testAccountId1, resourceUser1) val response = getAuthed("aisp", "accounts", acc) - response.code should equal(200) - val accounts = (response.body \ "Data" \ "Account").children - accounts should not be empty - (accounts.head \ "AccountId").extract[String] should equal(acc) - (accounts.head \ "Currency").extract[String] should equal("EUR") - } - scenario("authenticated with fake account id -> 200 empty Account list", UKOpenBankingV401AccountInfo) { - val response = getAuthed("aisp", "accounts", "fake-accountid") - response.code should equal(200) - (response.body \ "Data" \ "Account").children should be(empty) + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts", acc).code should equal(401) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala index 38b56ea712..db3d71bc99 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala @@ -78,4 +78,8 @@ trait UKOpenBankingV401ServerSetup extends ServerSetupWithTestData with DefaultU def deleteAuthed(segments: String*): APIResponse = makeDeleteRequest(v401(segments: _*).DELETE <@ (user1)) def deleteUnauthed(segments: String*): APIResponse = makeDeleteRequest(v401(segments: _*).DELETE) + + // For IDOR regression tests: user2 authenticated, but acting on a resource (e.g. a consent) + // that belongs to a different user (typically resourceUser1). + def deleteAuthedAsUser2(segments: String*): APIResponse = makeDeleteRequest(v401(segments: _*).DELETE <@ (user2)) } From 88d4a9a51de98243b8db7539e6b0deb5384edb9c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 00:34:44 +0200 Subject: [PATCH 28/63] docs: revert UK v2.0.0 consent note in CLAUDE.md --- CLAUDE.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 10af089e29..6ebe0882cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -265,8 +265,6 @@ Symptoms in tests: a v4-specific assertion fails (e.g. an entitlement should-be- **`isStatisticallyTooPermissive` is sample-pool-dependent**: a fresh local test DB with a single user trips the ABAC-permissiveness check and causes spurious rejections. Seed enough users in any test exercising ABAC rules — it's a test-data issue, not a regression. -**UK Open Banking v2.0.0 has no consent concept — intentionally left as-is**: unlike v3.1.0 and v4.0.1, the five real endpoints in `Http4sUKOBv200AIS.scala` (`/accounts`, `/accounts/ACCOUNT_ID`, `/balances`, `/accounts/ACCOUNT_ID/balances`, `/accounts/ACCOUNT_ID/transactions`) never call `checkUKConsent`, and the v2.0.0 standard defines no `account-access-consents` resource to create one against. This is **not** a data-exposure gap: every one of those handlers is self-scoping via `Views.views.vend.getPrivateBankAccountsFuture(u)` and/or `ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView(u, ...)`, so a caller only ever reaches their own accounts. Adding a consent gate here would mean inventing a resource the spec doesn't have, or borrowing v3.1/v4.0.1's consent store and thereby breaking the standard-boundary rule enforced by `assertConsentStandard`. Leave it alone; if a consent-scoped UK integration is needed, point it at v3.1.0 or v4.0.1. - ## CI (shard map + run tips) Perf note: integration tests are DB/HTTP-bound (~0.4 s/test) on both frameworks; the http4s win is the **pure-unit tier** (no running server, ~0.008 s/test). `ResourceDocsTest`/`SwaggerDocsTest` are the slowest per-test cost — they serialize the whole API surface, so cost grows with endpoint count. `Http4sResourceDocs` already caches the serialized output (`Caching.{getDynamic,getStatic,getAll}ResourceDocCache` + `getStaticSwaggerDocCache`, keyed via `APIUtil.createResourceDocCacheKey`), so repeat requests for the same version/params skip re-serialization. From a0a55f73c2e42754f3213746394c53594ceeec1b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 10:36:25 +0200 Subject: [PATCH 29/63] feat: allow a UK Open Banking consent to authenticate via the consent header UK Open Banking binds a consent to an OAuth2 access token, as a consent_id claim that checkUKConsent reads. That remains the case and is unchanged. This adds a second way to exercise the same consent: present it on its own, in a Consent-Id or Consent-JWT request header, with no Authorization header at all -- the shape Berlin Group and OBP-native clients already use. Previously a UK consent in either header was resolved, matched to its consumer and signature-verified, then rejected by the standard assertion on the OBP-native path with OBP-35036 ("required: obp"). There was no UK branch in the dispatcher at all. applyUKRules applies every gate checkUKConsent does -- standard, AUTHORISED status, not-before, expiry and the consumer match -- so the header path is never the weaker of the two, and adds a signature check the token path does not need (there OAuth2Login has already verified the token; here the caller may supply the JWT). It deliberately does not reuse applyConsentRules: that resolves the principal from the JWT's sub claim, which createUKConsentJWT sets to a fresh UUID rather than the PSU, so reusing it would mint a phantom user and grant it the consent's views. The PSU comes from MappedConsent.mUserId instead, bound during the authorise ceremony. No new grant logic is needed: grantUKConsentAccountAccess already writes the AccountAccess rows eagerly at authorise time, so per-permission scoping applies to the header path exactly as it does to the token path. --- .../main/scala/code/api/util/APIUtil.scala | 23 ++-- .../main/scala/code/api/util/ApiSession.scala | 5 + .../scala/code/api/util/ConsentUtil.scala | 100 ++++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) 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 b07c2fffce..58f77c38dd 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2806,21 +2806,24 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ consumerByCertificate } Consent.applyBerlinGroupRules(APIUtil.`getConsent-ID`(reqHeaders), cc.copy(consumer = consumerForConsent)) - } else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's Consent + } else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's or UK Open Banking's Consent val consentValue = APIUtil.getConsentJWT(reqHeaders) - Consent.getConsentJwtValueByConsentId(consentValue.getOrElse("")) match { + // Note: At this point we are getting the Consumer from the Consumer in the Consent. + // This may later be cross checked via the value in consumer_validation_method_for_consent. + // Get the source of truth for Consumer (e.g. CONSUMER_CERTIFICATE) as early as possible. + val ccWithConsumer = cc.copy(consumer = consumerByCertificate.orElse(consumerByConsumerKey)) + Consent.getConsentByHeaderValue(consentValue.getOrElse("")) match { + // A UK consent normally travels as a consent_id claim inside an OAuth2 access token. It may + // also be presented on its own here, with no Authorization header -- the same shape Berlin + // Group and OBP-native clients use. applyUKRules applies every gate checkUKConsent does. + case Some(consent) if consent.apiStandard == Consent.ConsentStandardUK => + Consent.applyUKRules(consent, consentValue.getOrElse(""), ccWithConsumer) case Some(consent) => // JWT value obtained via "Consent-Id" request header - Consent.applyRules( - Some(consent.jsonWebToken), - // Note: At this point we are getting the Consumer from the Consumer in the Consent. - // This may later be cross checked via the value in consumer_validation_method_for_consent. - // Get the source of truth for Consumer (e.g. CONSUMER_CERTIFICATE) as early as possible. - cc.copy(consumer = consumerByCertificate.orElse(consumerByConsumerKey)) - ) + Consent.applyRules(Some(consent.jsonWebToken), ccWithConsumer) case _ => JwtUtil.checkIfStringIsJWTValue(consentValue.getOrElse("")).isDefined match { case true => // It's JWT obtained via "Consent-JWT" request header - Consent.applyRules(APIUtil.getConsentJWT(reqHeaders), cc.copy(consumer = consumerByCertificate.orElse(consumerByConsumerKey))) + Consent.applyRules(APIUtil.getConsentJWT(reqHeaders), ccWithConsumer) case false => // Unrecognised consent value Future { (Failure(ErrorMessages.ConsentHeaderValueInvalid), None) } } diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 8bff206d57..7f6aa202b0 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -61,6 +61,11 @@ case class CallContext( counterparty: Option[CounterpartyTrait] = None, // Set when the request is authenticated via a consent. Persisted on metric rows for search/audit. consentReferenceId: Option[String] = None, + // Set when a UK Open Banking consent authenticated this request via the Consent-Id / + // Consent-JWT header rather than a Bearer token. checkUKConsent short-circuits on this: + // the consent has already been fully validated (standard, status, expiry, consumer, + // signature) and the PSU resolved from MappedConsent.mUserId. + ukConsentId: Option[String] = None, // How the caller's certificate was established (PeerTrust.Resolution.mode): // "direct", "forwarded" or "none". Persisted on metric rows as certificate_trust. certificateTrust: Option[String] = None, 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 5a308c18b4..2c2fc955a0 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -579,6 +579,22 @@ object Consent extends MdcLoggable { } } + /** + * Resolve the stored consent behind either spelling of the OBP consent header: a bare consent id + * in Consent-Id, or the consent JWT itself in Consent-JWT (whose jti is the consent id). + * + * Returns the row whatever standard created it -- the caller decides which gate to apply. Nothing + * here is authentication: the value is unverified caller input until one of the applyXxxRules + * functions has checked it. + */ + def getConsentByHeaderValue(headerValue: String): Option[MappedConsent] = { + getConsentJwtValueByConsentId(headerValue) // Consent-Id: a bare consent id + .orElse { // Consent-JWT: the consent JWT itself + JwtUtil.getOptionalClaim("jti", headerValue) + .flatMap(jti => Consents.consentProvider.vend.getConsentByConsentId(jti).toOption) + } + } + private def copyAuthContextOfConsentToUser(consentId: String, userId: String, newUser: Boolean): Box[List[UserAuthContext]] = { if(newUser) { val authContexts = ConsentAuthContextProvider.consentAuthContextProvider.vend.getConsentAuthContextsBox(consentId) @@ -719,6 +735,84 @@ object Consent extends MdcLoggable { case (None, _) => Future((Failure(ErrorMessages.ConsentHeaderNotFound), Some(callContext))) } } + /** + * Authenticate a request that presents a UK Open Banking consent as its sole credential, in a + * Consent-Id / Consent-JWT request header, with no Authorization header at all. + * + * UK Open Banking itself binds a consent to an OAuth2 access token (the consent_id claim that + * checkUKConsent reads). This is an OBP extension alongside that: it lets a client exercise a UK + * consent the way Berlin Group and OBP-native clients already exercise theirs. The token path is + * unaffected. + * + * Every gate checkUKConsent applies is applied here too -- standard, AUTHORISED status, not-before + * / expiry, and the consumer match -- so the header path is never the weaker of the two. The + * user-binding check that checkUKConsent performs (consent.mUserId == the token's user) has no + * analogue here and needs none: there is no independently-authenticated user to disagree with, + * because the PSU *is* resolved from the consent's own mUserId. + * + * Signature verification is stricter than the token path needs. On the token path OAuth2Login has + * already verified the access token's signature before checkUKConsent runs; here the caller may + * hand us the consent JWT itself, so it is verified against the consent's stored secret. + * + * Deliberately does NOT reuse applyConsentRules: that resolves the principal with + * getOrCreateUser(consent.sub, ...), and createUKConsentJWT sets sub to a fresh UUID rather than + * the PSU -- reusing it would mint a phantom user and grant it the consent's views. + */ + def applyUKRules(storedConsent: MappedConsent, + consentHeaderValue: String, + callContext: CallContext): Future[(Box[User], Option[CallContext])] = Future { + val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty = "consents.allowed", defaultValue = false) + if (!allowed) { + (Failure(ErrorMessages.ConsentDisabled), Some(callContext)) + } else { + val result: Box[(User, CallContext)] = for { + // A consent may only be exercised by its own standard. + _ <- assertConsentStandard(storedConsent, ConsentStandardUK) match { + case Some(failure) => failure + case None => Full(true) + } + _ <- if (storedConsent.status.toUpperCase == ConsentStatus.AUTHORISED.toString) Full(true) + else Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.AUTHORISED.toString}.") + currentTimeMillis = System.currentTimeMillis + _ <- if (currentTimeMillis >= storedConsent.creationDateTime.getTime) Full(true) + else Failure(ErrorMessages.ConsentNotBeforeIssue) + // A null expirationDateTime means the consent never expires (0..1 per spec, open-ended if + // absent -- see createUKConsentJWT). + _ <- if (!Option(storedConsent.expirationDateTime).exists(_.getTime < currentTimeMillis)) Full(true) + else Failure(ErrorMessages.ConsentExpiredIssue) + // Only meaningful when the caller supplied the JWT itself; a bare consent id carries + // nothing to forge, since the JWT is then read straight from our own row. + _ <- if (!JwtUtil.checkIfStringIsJWTValue(consentHeaderValue).isDefined) Full(true) + else if (verifyHmacSignedJwt(consentHeaderValue, storedConsent)) Full(true) + else Failure(ErrorMessages.ConsentVerificationIssue) + consentJwt <- { + implicit val dateFormats = CustomJsonFormats.formats + JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken).map(parse(_).extract[ConsentJWT]) + } ?~! ErrorMessages.ConsentNotFound + _ <- checkConsumerIsActiveAndMatchedUK( + consentJwt, + callContext.consumer.map(_.consumerId.get) + ) + // The PSU bound to the consent by updateConsentUser during the authorise ceremony. A + // consent that was never authorised has no user, and the status gate above already + // rejected it -- this is the belt to that braces. + user <- Users.users.vend.getUserByUserId(storedConsent.userId) ?~! ErrorMessages.ConsentNotFound + } yield { + (user, callContext.copy( + consenter = Full(user), + ukConsentId = Some(storedConsent.consentId), + consentReferenceId = Some(storedConsent.consentReferenceId) + )) + } + + result match { + case Full((user, updatedCallContext)) => (Full(user), Some(updatedCallContext)) + case failure@Failure(_, _, _) => (failure, Some(callContext)) + case _ => (Failure(ErrorMessages.ConsentNotFound), Some(callContext)) + } + } + } + def applyRulesOldStyle(consentId: Option[String], callContext: CallContext): (Box[User], CallContext) = { val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false) (consentId, allowed) match { @@ -1275,6 +1369,12 @@ object Consent extends MdcLoggable { * consent_id and exercise a consent they never authorised. */ def checkUKConsent(user: User, calContext: Option[CallContext]): Box[Boolean] = { + // The request may instead have been authenticated by the consent itself, presented in a + // Consent-Id / Consent-JWT header with no Authorization header at all. applyUKRules has then + // already run every check below -- and resolved this very user from the consent -- so there is + // nothing left to re-derive from a token that does not exist. + if (calContext.flatMap(_.ukConsentId).isDefined) return Full(true) + val accessToken = calContext.flatMap(_.authReqHeaderField) .map(_.replaceFirst("Bearer\\s+", "")) .getOrElse(throw new RuntimeException("Not found http request header 'Authorization', it is mandatory.")) From 80903f6da73e692ecfb3d287777978ff34395223 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 11:17:37 +0200 Subject: [PATCH 30/63] fix: make a UK consent authoritative for the permissions it declares Re-authorising with a narrower set of permissions did not narrow anything. grantAccessToViews only revokes-and-regrants the views named in the consent it is handed, so a permission granted by an earlier, broader consent survived indefinitely: after any consent covering ReadBalances, a later ReadAccountsBasic-only consent could still read balances. Verified against a clean PSU before the fix -- authorising a consent declaring only ReadAccountsBasic left all six previously granted UK views in place, and every data endpoint answered 200 regardless of what the consent said. After it, the same consent leaves exactly ReadAccountsBasic and balances/transactions return 403 OBP-20017. The revoke is scoped to the seven UK permission views on the accounts this consent binds. owner and ManageCustomViews come from account ownership, and the *BerlinGroup views from the other standard; neither is this consent's to touch. Known limitation, called out at the call site: these grants are ALL_CONSUMERS, so two concurrent UK consents from different TPPs over the same account cannot be told apart here and the later authorisation trims the earlier one. Scoping grants by consumer_id (AccountAccess already has the column) is the real fix and is left out of this change. --- .../scala/code/api/util/ConsentUtil.scala | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 2c2fc955a0..2897f24b4e 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1281,6 +1281,39 @@ object Consent extends MdcLoggable { val jwtPayloadAsJson = compactRender(Extraction.decompose(updatedPayload)) val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) val jwt = CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret) + // Drop the UK permission views this consent does NOT declare, on the accounts it binds. + // grantAccessToViews only revokes-and-regrants the views named in the consent it is given, + // so without this a permission granted by an earlier, broader consent survives forever and + // silently widens every later one: after any consent covering ReadBalances, a subsequent + // ReadAccountsBasic-only consent would still read balances. The consent has to be + // authoritative for its own accounts, or narrowing it means nothing. + // + // Deliberately narrow: only the seven UK permission views, only on the accounts named in + // this consent. owner / ManageCustomViews come from account ownership and the + // *BerlinGroup views from the other standard — none of those are this consent's to revoke. + // + // Caveat: these grants are ALL_CONSUMERS, so two concurrent UK consents from different + // TPPs over the same account are indistinguishable here and the later authorisation trims + // the earlier one's permissions. Fixing that properly means scoping the grants by + // consumer_id (AccountAccess already has the column); out of scope here. + val ukPermissionViewIds: Set[String] = Set( + Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID, + Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID, + Constant.SYSTEM_READ_BALANCES_VIEW_ID, + Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID, + Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID, + Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, + Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID + ) + for { + accountId <- validatedAccountIds + staleViewId <- (ukPermissionViewIds -- permissions.toSet).toList + } { + // Idempotent: a view the PSU never held simply reports CannotFindAccountAccess. + Views.views.vend.revokeAccess( + BankIdAccountIdViewId(bankId, AccountId(accountId), ViewId(staleViewId)), user) + } + // Eagerly grant real AccountAccess now: UK consents are exercised via an opaque OAuth2 // Bearer token (checkUKConsent), not the Consent-JWT header BG/OBP consents use to // lazily re-derive access on every call — so the grant has to happen once, here. From e62463fec32b522a14ebd355231cea6a66765c01 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 17:31:39 +0200 Subject: [PATCH 31/63] feat: link Hola's status and health probes from the app directory Every other app on the discovery page carries [status] and [health] links; Hola had a URL but no probes, because appProbes is a fixed map and it was never added. The links resolve to {public_obp_hola_url}/status and /health, which Hola now serves. --- obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala b/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala index fc076c4575..4c2f77ec21 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala @@ -41,6 +41,7 @@ object AppsPage { "public_obp_opey_url" -> Set("status", "health"), "public_obp_api_explorer_url" -> Set("status", "health"), "public_obp_mcp_url" -> Set("status", "health", "ready"), + "public_obp_hola_url" -> Set("status", "health"), ) private def probesFor(key: String): List[String] = From 6727bd3726a1103aad8f45c98a6544c20cabc87a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 17:58:19 +0200 Subject: [PATCH 32/63] feat: flag a dirty build on the status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stamp already carried git.dirty; the page ignored it, so a commit id built from a modified working tree rendered exactly like one built from a clean checkout. That id is then misleading: it names the last commit, and checking it out would not reproduce the running artifact. Shown next to the commit in HTML and as git_dirty in the JSON. A clean build is unchanged — no marker, no note — so the flag only appears when it means something. Older stamps predating the key default to false rather than failing. --- .../code/api/util/http4s/StatusPage.scala | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index db1e41ac6c..e70b95dc06 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -27,6 +27,15 @@ object StatusPage extends MdcLoggable { private def gitCommit: String = Option(gitProps.getProperty("git.commit.id")).getOrElse("unknown") + /** + * True when the build had uncommitted changes. Reported alongside the commit because the plugin + * stamps the last commit, not what was compiled: a build from a modified working tree names a + * revision whose source never produced this artifact, and checking that id out would not + * reproduce it. Absent from older stamps, so it defaults to false rather than failing. + */ + private def gitDirty: Boolean = + Option(gitProps.getProperty("git.dirty")).exists(_.trim.equalsIgnoreCase("true")) + private def apiInstanceId: String = code.api.Constant.ApiInstanceId private def uptimeSeconds: Long = @@ -159,6 +168,7 @@ object StatusPage extends MdcLoggable { | "status": "$status", | "api_instance_id": "$apiInstanceId", | "git_commit": "$gitCommit", + | "git_dirty": $gitDirty, | "uptime_seconds": $uptimeSeconds, | "checks": { | "database": "${checks.database}", @@ -211,7 +221,14 @@ object StatusPage extends MdcLoggable { |

Instance

| | - | + | | |
api_instance_id$apiInstanceId
git_commit$gitCommit
git_commit$gitCommit${ + if (gitDirty) + """ dirty""" + + """
""" + + "built from a working tree with uncommitted changes — this names the last " + + "commit, not necessarily what is running
" + else "" + }
uptime_seconds$uptimeSeconds
| From 29ed344208ebe0b1030ff1d5b9ad52e56c8f76ae Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 20:21:13 +0200 Subject: [PATCH 33/63] test: pin down what a UK consent's permissions are worth alongside other consents UK is the only standard that materialises consent permissions onto the real PSU. Berlin Group and OBP-native mint a fresh shadow user per consent -- every createXxxConsentJWT sets `sub` to a random UUID and applyConsentRules resolves the principal from it -- so their AccountAccess rows are isolated by construction. grantUKConsentAccountAccess grants to the authorising PSU instead, so every UK consent for that PSU competes for the same (user, account, view) rows, and those rows record neither which consent nor which consumer produced them. Three scenarios, none of which any existing suite covered: narrowing a consent must actually narrow it, one TPP's authorisation must not rewrite another TPP's access to the same account, and neither may disturb the account-ownership grants. Asserted through UserExtended.hasAccountAccess, which is what checkViewAccessAndReturnView -- and so every UK data endpoint -- consults. The HTTP path can't be driven from here: these OAuth1-signed requests carry no Bearer JWT with a consent_id claim and would stop at 403 ConsentIdClaimMissing. The two-TPP scenario fails on the current code, which is the point of committing it first: authorising the second consent deletes the first TPP's ReadBalances row, because the stale-view purge added with the narrowing fix runs at ALL_CONSUMERS and cannot tell the two apart. --- ...UKOpenBankingV401ConsentScopingTests.scala | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala new file mode 100644 index 0000000000..5065b5a561 --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -0,0 +1,131 @@ +package code.api.UKOpenBanking.v4_0_1 + +import code.api.Constant +import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.{CallContext, Consent} +import code.consent.Consents +import code.model.UserExtended +import code.views.Views +import code.views.system.AccountAccess +import com.openbankproject.commons.model.{BankIdAccountId, ViewId} +import net.liftweb.common.Full +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * What a UK consent's declared Permissions are actually worth, once more than one consent exists. + * + * UK is the only standard that materialises consent permissions onto the *real* PSU: Berlin Group + * and OBP-native mint a fresh shadow user per consent (createXxxConsentJWT sets sub to a random + * UUID, and applyConsentRules resolves the principal with getOrCreateUser(consent.sub, ...)), so + * their AccountAccess rows are isolated by construction. grantUKConsentAccountAccess instead grants + * to the authorising PSU, so every UK consent for that PSU lands on the same (user, account, view) + * rows -- and those rows carry no consent identity at all. + * + * That makes two properties worth pinning down, neither of which any existing suite covers: + * - narrowing: re-authorising with fewer permissions must actually drop the ones left out; + * - isolation: one TPP's authorisation must not rewrite another TPP's access to the same account. + * + * Asserted at the UserExtended.hasAccountAccess layer because that is exactly what + * APIUtil.checkViewAccessAndReturnView -- and therefore every UK data endpoint -- consults. The + * full HTTP path can't be driven here: these OAuth1-signed requests carry no Bearer JWT with a + * consent_id claim, so the data endpoints would stop at 403 ConsentIdClaimMissing. + */ +class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup { + + object UKConsentScoping extends Tag("UKConsentScoping") + + private val acc = testAccountId1.value + private val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + + private val ReadAccountsBasic = Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID + private val ReadBalances = Constant.SYSTEM_READ_BALANCES_VIEW_ID + + private def systemView(viewId: String) = + Views.views.vend.getOrCreateSystemView(viewId).openOrThrowException(s"could not create system view $viewId") + + /** + * Create a UK consent held by `consumerId` and run the authorise-time grant on it, i.e. the same + * call the POST /consents/CONSENT_ID/authorise endpoint makes once SCA has passed. + */ + private def authoriseConsentFor(consumerId: String, permissions: List[String]): Unit = { + val consentId = Consents.consentProvider.vend.saveUKConsent( + user = Some(resourceUser1), + bankId = None, + accountIds = None, + consumerId = Some(consumerId), + permissions = permissions, + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("test consent creation failed").consentId + + // saveUKConsent hands back the ConsentTrait; grantUKConsentAccountAccess wants the MappedConsent. + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException(s"consent $consentId not found") + + // The seven UK permission views are not seeded in the test DB (Boot only creates + // owner/auditor/accountant/... unless additional_system_views is set, and test.default.props + // does not set it), so they have to exist before the grant can bind them. + permissions.foreach(systemView) + + Await.result( + Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, List(acc), consent, None), + 10.seconds) + } + + /** Access as it is evaluated for a request arriving from `consumer` -- the consumer is what + * User.hasAccountAccess keys its consumer-specific lookup on before falling back to ALL_CONSUMERS. */ + private def canRead(viewId: String, consumer: code.model.Consumer): Boolean = + UserExtended(resourceUser1).hasAccountAccess( + systemView(viewId), + bankIdAccountId, + Some(CallContext(consumer = Full(consumer)))) + + feature("A UK consent is authoritative for the permissions it declares") { + scenario("re-authorising with fewer permissions drops the ones left out", UKConsentScoping) { + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) + canRead(ReadAccountsBasic, testConsumer) should equal(true) + canRead(ReadBalances, testConsumer) should equal(true) + + // Same TPP, narrower consent: ReadBalances was not asked for this time, so it must go. + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + canRead(ReadAccountsBasic, testConsumer) should equal(true) + canRead(ReadBalances, testConsumer) should equal(false) + } + } + + feature("One TPP's UK consent does not rewrite another TPP's access") { + scenario("a second consumer authorising a narrower consent leaves the first consumer's access intact", UKConsentScoping) { + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) + authoriseConsentFor(testConsumer2.consumerId.get, List(ReadAccountsBasic)) + + // The second TPP never asked for balances, so it must not have them. + canRead(ReadBalances, testConsumer2) should equal(false) + + // ...and the first TPP's consent is none of the second TPP's business: authorising a consent + // must not narrow access that was granted to somebody else. + canRead(ReadBalances, testConsumer) should equal(true) + } + } + + feature("A UK consent grant leaves account-ownership access alone") { + scenario("the owner view survives, still granted to every consumer", UKConsentScoping) { + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + + // owner comes from holding the account, not from any consent, so it stays ALL_CONSUMERS and + // must never be caught by a consent's revoke pass. + AccountAccess.findByUniqueIndex( + testBankId1, + testAccountId1, + ViewId(Constant.SYSTEM_OWNER_VIEW_ID), + resourceUser1.userPrimaryKey, + Constant.ALL_CONSUMERS + ).isDefined should equal(true) + } + } +} From e96c8becdfe9ab012d98233f4c12f4346eb398d2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 20:21:36 +0200 Subject: [PATCH 34/63] fix: scope UK consent account access to the granting consumer AccountAccess has always had a consumer_id column, and User.hasAccountAccess has always read it -- consumer-specific row first, ALL_CONSUMERS as the fallback. Nothing ever wrote a real one: getOrGrantAccessToViewCommon hardcoded ALL_CONSUMERS, so every consent's grants landed on rows shared with account ownership and with every other application. Consent-scoped access was indistinguishable from access the PSU has by holding the account. That is one defect with two faces. A narrower consent could not narrow anything, because the permissions an earlier consent granted were still sitting on rows nobody could attribute; and the ALL_CONSUMERS purge added to fix that then deleted other TPPs' permissions along with the stale ones. So the write side now says who a grant belongs to. getOrGrantAccessToViewCommon takes a consumerId, defaulting to ALL_CONSUMERS so account ownership and the CBS refresh keep writing shared rows exactly as before. grantAccessTo{System,Custom}ViewForConsumer mirror the revokeAccessTo*ForConsumer pair that already existed -- delete-side plumbing for a grant side that was never built. revokeAccessToViewForUserAndConsumer matches on the full unique index: revokeAccess ignores the consumer and the existing revoke...ForConsumer helpers ignore the user, which on a joint account would delete whichever row came back first. Only UK routes through it. grantAccessToViews keeps ALL_CONSUMERS as its default, so the Berlin Group and OBP-native call sites are untouched -- they have no need for it, their per-consent shadow users already isolate them. The stale-view purge now runs twice per bound account: this consumer's rows for the seven UK permission views the consent no longer declares, and every ALL_CONSUMERS row for those views regardless of what it declares. The second pass is what makes the fix real for existing data. Account ownership never grants those seven (see LocalMappedConnector's viewsToGenerate), so such a row can only be a pre-consumer-scoping consent artefact -- and leaving it would let hasAccountAccess fall back onto it and answer for a consent that never asked. Each account heals on its next authorisation; a TPP whose access predates this change re-authorises to get its own rows. Verified end to end against the local stack with a clean PSU: two TPPs authorising over the same account now write separate rows and neither trims the other, the eight-case permission matrix is unchanged, and owner / ManageCustomViews / the Berlin Group views all remain ALL_CONSUMERS. Residual, and the reason AccountAccess still wants a consent_id: two concurrent consents from the *same* TPP over the same account remain indistinguishable. --- .../scala/code/api/util/ConsentUtil.scala | 65 ++++++++++---- .../main/scala/code/views/MapperViews.scala | 84 +++++++++++++++++-- obp-api/src/main/scala/code/views/Views.scala | 7 ++ 3 files changed, 136 insertions(+), 20 deletions(-) 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 2897f24b4e..85fd187ff9 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -389,12 +389,25 @@ object Consent extends MdcLoggable { } - private def grantAccessToViews(user: User, consent: ConsentJWT): Box[User] = { + /** + * Materialise a consent's views as AccountAccess rows. + * + * consumerId defaults to ALL_CONSUMERS, which is what Berlin Group and OBP-native want: each of + * their consents already resolves to its own shadow user (createXxxConsentJWT gives every consent + * a random `sub`, and applyConsentRules turns that into a distinct user), so their grants are + * isolated by construction and there is nothing for a consumer to disambiguate. + * + * UK is the exception -- it grants to the real PSU -- so it passes the consent's own consumerId + * and gets rows only that TPP can claim. + */ + private def grantAccessToViews(user: User, consent: ConsentJWT, consumerId: String = Constant.ALL_CONSUMERS): Box[User] = { + val isConsumerScoped = consumerId != Constant.ALL_CONSUMERS for { view <- consent.views } yield { val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) - Views.views.vend.revokeAccess(bankIdAccountIdViewId, user) + if (isConsumerScoped) Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, consumerId) + else Views.views.vend.revokeAccess(bankIdAccountIdViewId, user) } val result: List[Box[View]] = { for { @@ -403,10 +416,16 @@ object Consent extends MdcLoggable { val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) Views.views.vend.systemView(ViewId(view.view_id)) match { case Full(systemView) => - Views.views.vend.grantAccessToSystemView(BankId(view.bank_id), AccountId(view.account_id), systemView, user) + if (isConsumerScoped) + Views.views.vend.grantAccessToSystemViewForConsumer(BankId(view.bank_id), AccountId(view.account_id), systemView, user, consumerId) + else + Views.views.vend.grantAccessToSystemView(BankId(view.bank_id), AccountId(view.account_id), systemView, user) case _ => // It's not system view - Views.views.vend.grantAccessToCustomView(bankIdAccountIdViewId, user) + if (isConsumerScoped) + Views.views.vend.grantAccessToCustomViewForConsumer(bankIdAccountIdViewId, user, consumerId) + else + Views.views.vend.grantAccessToCustomView(bankIdAccountIdViewId, user) } } } @@ -1289,13 +1308,18 @@ object Consent extends MdcLoggable { // authoritative for its own accounts, or narrowing it means nothing. // // Deliberately narrow: only the seven UK permission views, only on the accounts named in - // this consent. owner / ManageCustomViews come from account ownership and the - // *BerlinGroup views from the other standard — none of those are this consent's to revoke. + // this consent, and only rows belonging to this consent's own consumer -- owner / + // ManageCustomViews come from account ownership, the *BerlinGroup views from the other + // standard, and another TPP's rows are that TPP's business, not this consent's. // - // Caveat: these grants are ALL_CONSUMERS, so two concurrent UK consents from different - // TPPs over the same account are indistinguishable here and the later authorisation trims - // the earlier one's permissions. Fixing that properly means scoping the grants by - // consumer_id (AccountAccess already has the column); out of scope here. + // The second pass sweeps the same views at ALL_CONSUMERS. Those rows can only have come + // from a UK consent authorised before grants carried a consumer (account ownership never + // grants these seven -- see LocalMappedConnector's viewsToGenerate), and leaving them would + // silently defeat the whole fix: User.hasAccountAccess falls back to ALL_CONSUMERS when it + // finds no consumer-specific row, so a pre-existing ReadBalances row would still answer for + // a consent that never asked for balances. Each account heals the first time it is + // re-authorised; the cost is that a TPP whose access predates this change has to + // re-authorise to get its own scoped rows back. val ukPermissionViewIds: Set[String] = Set( Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID, Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID, @@ -1305,20 +1329,33 @@ object Consent extends MdcLoggable { Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID ) + // A consent without a consumer cannot own rows of its own; fall back to the shared scope so + // it behaves exactly as it did before, rather than writing rows under an empty consumer id. + val consentConsumerId = + Option(consent.consumerId).map(_.trim).filterNot(_.isEmpty).getOrElse(Constant.ALL_CONSUMERS) for { accountId <- validatedAccountIds - staleViewId <- (ukPermissionViewIds -- permissions.toSet).toList + viewId <- ukPermissionViewIds.toList } { + val bankIdAccountIdViewId = BankIdAccountIdViewId(bankId, AccountId(accountId), ViewId(viewId)) // Idempotent: a view the PSU never held simply reports CannotFindAccountAccess. - Views.views.vend.revokeAccess( - BankIdAccountIdViewId(bankId, AccountId(accountId), ViewId(staleViewId)), user) + // Legacy shared rows go regardless of what this consent declares -- the declared ones are + // re-granted under this consumer immediately below, so nothing this consent is entitled to + // is lost, and nothing it is not entitled to survives for another TPP to inherit. + Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, Constant.ALL_CONSUMERS) + if (!permissions.contains(viewId)) { + Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, consentConsumerId) + } } // Eagerly grant real AccountAccess now: UK consents are exercised via an opaque OAuth2 // Bearer token (checkUKConsent), not the Consent-JWT header BG/OBP consents use to // lazily re-derive access on every call — so the grant has to happen once, here. + // + // Scoped to this consent's consumer, so the rows are this TPP's alone: another TPP's + // consent over the same account neither reads nor rewrites them. updatedPayload.foreach { consentJwt => - grantAccessToViews(user, consentJwt) match { + grantAccessToViews(user, consentJwt, consentConsumerId) match { case Failure(msg, _, _) => logger.warn(s"grantUKConsentAccountAccess: grantAccessToViews reported: $msg") case _ => diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 06d32c8eb4..7f1669c009 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -130,22 +130,28 @@ object MapperViews extends Views with MdcLoggable { Full(Permission(user, getViewsForUser(user))) } // This is an idempotent function - private def getOrGrantAccessToViewCommon(user: User, viewDefinition: View, bankId: String, accountId: String): Box[View] = { + // + // consumerId defaults to ALL_CONSUMERS, which is what account ownership wants: holding an account + // is not something any one application owns. A grant that IS owned by one application -- a consent + // exercised by a specific TPP -- passes that TPP's consumerId instead, so it becomes a row of its + // own rather than sharing (and overwriting) everybody else's. User.hasAccountAccess already reads + // it that way: consumer-specific row first, ALL_CONSUMERS as the fallback. + private def getOrGrantAccessToViewCommon(user: User, viewDefinition: View, bankId: String, accountId: String, consumerId: String = ALL_CONSUMERS): Box[View] = { if (AccountAccess.findByUniqueIndex( BankId(bankId), - AccountId(accountId), + AccountId(accountId), viewDefinition.viewId, - user.userPrimaryKey, - ALL_CONSUMERS).isEmpty) { + user.userPrimaryKey, + consumerId).isEmpty) { logger.debug(s"getOrGrantAccessToViewCommon AccountAccess.create" + - s"user(UserId(${user.userId}), ViewId(${viewDefinition.viewId.value}), bankId($bankId), accountId($accountId), consumerId($ALL_CONSUMERS)") + s"user(UserId(${user.userId}), ViewId(${viewDefinition.viewId.value}), bankId($bankId), accountId($accountId), consumerId($consumerId)") // SQL Insert AccountAccessList val saved = AccountAccess.create. user_fk(user.userPrimaryKey.value). bank_id(bankId). account_id(accountId). view_id(viewDefinition.viewId.value). - consumer_id(ALL_CONSUMERS). + consumer_id(consumerId). save if (saved) { //logger.debug("saved AccountAccessList") @@ -190,6 +196,32 @@ object MapperViews extends Views with MdcLoggable { } } + // The consumer-scoped counterparts of the two grants above, mirroring the revoke...ForConsumer + // pair further down. Access granted through one application's consent belongs to that application + // alone: keeping it on its own row is what stops a second TPP's consent from rewriting it, and + // what makes narrowing a consent narrow only that consent. + def grantAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view: View, user: User, consumerId: String): Box[View] = { + { view.isPublic && !allowPublicViews } match { + case true => Failure(PublicViewsNotAllowedOnThisInstance) + case false => getOrGrantAccessToViewCommon(user, view, bankId.value, accountId.value, consumerId) + } + } + + def grantAccessToCustomViewForConsumer(bankIdAccountIdViewId: BankIdAccountIdViewId, user: User, consumerId: String): Box[View] = { + val viewDefinition = ViewDefinition.findCustomView( + bankIdAccountIdViewId.bankId.value, + bankIdAccountIdViewId.accountId.value, + bankIdAccountIdViewId.viewId.value) + + viewDefinition match { + case Full(v) => + if (v.isPublic && !allowPublicViews) Failure(PublicViewsNotAllowedOnThisInstance) + else getOrGrantAccessToViewCommon(user, v, bankIdAccountIdViewId.bankId.value, bankIdAccountIdViewId.accountId.value, consumerId) + case _ => + Empty ~> APIFailure(s"View ${bankIdAccountIdViewId.viewId} not found", 404) + } + } + def grantAccessToMultipleViews(views: List[BankIdAccountIdViewId], user: User, callContext: Option[CallContext]): Box[List[View]] = { val viewDefinitions: List[(ViewDefinition, BankIdAccountIdViewId)] = views.map { uid => ViewDefinition.findCustomView(uid.bankId.value,uid.accountId.value, uid.viewId.value).map((_, uid)) @@ -316,6 +348,46 @@ object MapperViews extends Views with MdcLoggable { } } + /** + * Revoke exactly one grant: this user's, under this consumer, on this view. + * + * revokeAccess matches on (bank, account, view, user) and so cannot tell two applications' grants + * apart; revokeAccessTo*ForConsumer match on (bank, account, view, consumer) and so cannot tell + * two users apart -- on a joint account they would delete whichever row came back first. Undoing + * one consent's grant needs both, hence AccountAccess's full unique index. + */ + def revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId: BankIdAccountIdViewId, user: User, consumerId: String): Box[Boolean] = { + def accountAccessRow = AccountAccess.findByUniqueIndex( + bankIdAccountIdViewId.bankId, + bankIdAccountIdViewId.accountId, + bankIdAccountIdViewId.viewId, + user.userPrimaryKey, + consumerId + ) ?~! CannotFindAccountAccess + + val isRevokedCustomViewAccess = + for { + _ <- ViewDefinition.findCustomView( + bankIdAccountIdViewId.bankId.value, + bankIdAccountIdViewId.accountId.value, + bankIdAccountIdViewId.viewId.value) + accountAccess <- accountAccessRow + } yield { + accountAccess.delete_! + } + + val isRevokedSystemViewAccess = + for { + systemViewDefinition <- ViewDefinition.findSystemView(bankIdAccountIdViewId.viewId.value) + accountAccess <- accountAccessRow + _ <- canRevokeOwnerAccessAsBox(bankIdAccountIdViewId.bankId, bankIdAccountIdViewId.accountId, systemViewDefinition, user) + } yield { + accountAccess.delete_! + } + + isRevokedCustomViewAccess or isRevokedSystemViewAccess + } + //returns Full if deletable, Failure if not def canRevokeOwnerAccessAsBox(bankId: BankId, accountId: AccountId, viewDefinition : ViewDefinition, user : User) : Box[Unit] = { if(canRevokeOwnerAccess(bankId: BankId, accountId: AccountId, viewDefinition, user)) Full(Unit) diff --git a/obp-api/src/main/scala/code/views/Views.scala b/obp-api/src/main/scala/code/views/Views.scala index 4a462272bb..c1210cd0b0 100644 --- a/obp-api/src/main/scala/code/views/Views.scala +++ b/obp-api/src/main/scala/code/views/Views.scala @@ -36,6 +36,13 @@ trait Views { def revokeAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view : View, consumerId : String) : Box[Boolean] def revokeAccessToCustomViewForConsumer(view : View, consumerId : String) : Box[Boolean] + // Grant/revoke a single application's access, rather than access shared by every application. + // Used by the consent flows: what one TPP's consent grants is that TPP's alone, so narrowing or + // re-authorising it must not touch another TPP's grants on the same account. + def grantAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view : View, user : User, consumerId : String) : Box[View] + def grantAccessToCustomViewForConsumer(bankIdAccountIdViewId : BankIdAccountIdViewId, user : User, consumerId : String) : Box[View] + def revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId : BankIdAccountIdViewId, user : User, consumerId : String) : Box[Boolean] + def customView(viewId : ViewId, bankAccountId: BankIdAccountId) : Box[View] def systemView(viewId : ViewId) : Box[View] def customViewFuture(viewId : ViewId, bankAccountId: BankIdAccountId) : Future[Box[View]] From 752dc9437dad19950a70a2155585b8f4007d300a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 31 Jul 2026 20:24:35 +0200 Subject: [PATCH 35/63] fix: reject invalid UK consent Permissions combinations with 400 The Account and Transaction API profile lists permission combinations an ASPSP must reject with a 400 response code. Both UK account-access-consent endpoints accepted all of them, so a consent could be lodged that no AISP can ever use: every AIS endpoint other than /accounts is /accounts/{AccountId}/..., so a consent naming no account-read permission can never discover the ids it would need. Such a consent authorises normally and then returns an empty account list forever, with nothing to explain why. Add Consent.validateUKConsentPermissions covering the profile's rules -- a non-empty array, at least one of ReadAccountsBasic/ReadAccountsDetail, and transaction depth and direction each requiring the other -- and call it before saving the consent in the v3.1 and v4.0.1 handlers. Two rules are deliberately not enforced. "A permission code not supported by the ASPSP" is about the endpoint subset an ASPSP publishes, and OBP publishes no such list, so rejecting on it would be guesswork; a code that is not a UK permission code at all is still refused. Requesting both a Basic and its Detail counterpart stays allowed, because the profile calls that duplication but forbids rejecting on that basis alone. --- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 12 +- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 12 +- .../scala/code/api/util/ConsentUtil.scala | 67 +++++++++ .../scala/code/api/util/ErrorMessages.scala | 1 + ...enBankingV310ConsentPermissionsTests.scala | 69 +++++++++ ...enBankingV401ConsentPermissionsTests.scala | 132 ++++++++++++++++++ 6 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala create mode 100644 obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index e5af52447b..654de87498 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -8,10 +8,10 @@ import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBo import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.CallContext -import code.api.util.{ConsentJWT, JwtUtil, NewStyle} +import code.api.util.{Consent, ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents import code.util.Helper import code.util.Helper.MdcLoggable @@ -77,6 +77,14 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { consentJson.Data.TransactionToDateTime.map(parseIso8601OrDayDate) ) } + // The standard requires the ASPSP to refuse malformed permission combinations with 400 + // rather than create a consent that can never be exercised -- see + // Consent.validateUKConsentPermissions for the rules and why they matter. + _ <- Consent.validateUKConsentPermissions(consentJson.Data.Permissions) match { + case Some(reason) => + Helper.booleanToFuture(s"$InvalidUKConsentPermissions$reason", 400, Some(cc))(false) + case None => Future.successful(true) + } consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 52f97ed02b..c088d27e7d 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -9,10 +9,10 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyR import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle -import code.api.util.{APIUtil, ConsentJWT, JwtUtil, NewStyle} +import code.api.util.{APIUtil, Consent, ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents import code.model.{BankAccountExtended, UserExtended} import code.util.Helper @@ -127,6 +127,14 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { consentJson.Data.TransactionToDateTime.map(parseIso8601OrDayDate) ) } + // The standard requires the ASPSP to refuse malformed permission combinations with 400 + // rather than create a consent that can never be exercised -- see + // Consent.validateUKConsentPermissions for the rules and why they matter. + _ <- Consent.validateUKConsentPermissions(consentJson.Data.Permissions) match { + case Some(reason) => + Helper.booleanToFuture(s"$InvalidUKConsentPermissions$reason", 400, Some(cc))(false) + case None => Future.successful(true) + } consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( 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 85fd187ff9..69408d37a4 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1167,6 +1167,73 @@ object Consent extends MdcLoggable { Full(CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret)) } + /** + * The permission codes UK Open Banking defines for an account-access-consent. + * + * Taken from the Account and Transaction API profile's permission table. Codes outside this set + * are not UK permission codes at all, which is a different thing from a code this ASPSP happens + * not to implement -- see validateUKConsentPermissions. + */ + private val ukPermissionCodes: Set[String] = Set( + "ReadAccountsBasic", "ReadAccountsDetail", + "ReadBalances", + "ReadBeneficiariesBasic", "ReadBeneficiariesDetail", + "ReadDirectDebits", + "ReadOffers", + "ReadPAN", + "ReadParty", "ReadPartyPSU", + "ReadProducts", + "ReadScheduledPaymentsBasic", "ReadScheduledPaymentsDetail", + "ReadStandingOrdersBasic", "ReadStandingOrdersDetail", + "ReadStatementsBasic", "ReadStatementsDetail", + "ReadTransactionsBasic", "ReadTransactionsCredits", + "ReadTransactionsDebits", "ReadTransactionsDetail" + ) + + /** + * Check a UK account-access-consent's Permissions array against the combinations the standard + * forbids, returning the reason it must be refused or None when it is well formed. + * + * The Account and Transaction API profile requires the ASPSP to reject these with 400, and the + * rules are not arbitrary: every AIS endpoint other than /accounts is /accounts/{AccountId}/..., + * so a consent without an account-read permission can never discover the ids it would need and + * is a dead end -- it authorises data the AISP has no way to reach. Accepting one produces a + * consent that looks authorised and returns an empty account list forever. + * + * Deliberately not enforced: "a permission code that is not supported by the ASPSP". That rule + * is about the endpoint subset an ASPSP publishes, and OBP publishes no such list, so rejecting + * on it would be guesswork. A code that is not a UK permission code at all is still refused. + * + * Requesting both a Basic and its corresponding Detail code is explicitly allowed: the profile + * calls it duplication but forbids rejecting on that basis alone. + */ + def validateUKConsentPermissions(permissions: List[String]): Option[String] = { + val granted = permissions.toSet + val transactionDepth = Set("ReadTransactionsBasic", "ReadTransactionsDetail") + val transactionDirection = Set("ReadTransactionsCredits", "ReadTransactionsDebits") + + if (permissions.isEmpty) { + Some("The Permissions array must not be empty.") + } else { + val unknown = granted.diff(ukPermissionCodes) + if (unknown.nonEmpty) { + Some(s"Unknown permission code(s): ${unknown.toList.sorted.mkString(", ")}.") + } else if (granted.intersect(Set("ReadAccountsBasic", "ReadAccountsDetail")).isEmpty) { + Some("The Permissions array must contain at least one of ReadAccountsBasic and ReadAccountsDetail.") + } else if (granted.intersect(transactionDepth).nonEmpty && + granted.intersect(transactionDirection).isEmpty) { + Some("A Permissions array containing ReadTransactionsBasic or ReadTransactionsDetail must also " + + "contain at least one of ReadTransactionsCredits and ReadTransactionsDebits.") + } else if (granted.intersect(transactionDirection).nonEmpty && + granted.intersect(transactionDepth).isEmpty) { + Some("A Permissions array containing ReadTransactionsCredits or ReadTransactionsDebits must also " + + "contain at least one of ReadTransactionsBasic and ReadTransactionsDetail.") + } else { + None + } + } + } + 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 5d28d82da5..37750668f3 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -773,6 +773,7 @@ object ErrorMessages { val ConsentIdClaimMissing = "OBP-35035: The access token is not bound to a Consent. The identity provider must include a consent_id claim in access tokens issued via the consent authorisation flow. " val ConsentDoesNotMatchStandard = "OBP-35036: The Consent was created by a different API standard than the endpoint using it. A consent may only be used by endpoints of the standard that created it. " val ConsentAccountNotHeldByUser = "OBP-35037: One or more of the specified account_ids is not held by the current user. A consent may only be authorised for accounts the authorising user holds. " + val InvalidUKConsentPermissions = "OBP-35038: The Permissions array is not a valid combination for UK Open Banking. " //Authorisations val AuthorisationNotFound = "OBP-36001: Authorisation not found. Please specify valid values for PAYMENT_ID and AUTHORISATION_ID. " diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala new file mode 100644 index 0000000000..648dedf66e --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala @@ -0,0 +1,69 @@ +package code.api.UKOpenBanking.v3_1_0 + +import code.api.util.ErrorMessages.InvalidUKConsentPermissions +import com.openbankproject.commons.model.ErrorMessage +import org.scalatest.Tag + +// v3.1 lodges account-access consents through its own handler, so the permission-combination rules +// have to be asserted here as well as on v4.0.1 -- the rule function being shared is not by itself +// evidence that this endpoint calls it. The rules themselves, and why an unusable consent is worth +// refusing outright, are covered in UKOpenBankingV401ConsentPermissionsTests. +class UKOpenBankingV310ConsentPermissionsTests extends UKOpenBankingV310ServerSetup { + + object UKOpenBankingV310ConsentPermissions extends Tag("UKOpenBankingV310ConsentPermissions") + + // v3.1's ConsentPostBodyUKV310 types Risk as a String, not an object. + private def body(permissions: String): String = + s"""{ + | "Data": { + | "Permissions": $permissions, + | "ExpirationDateTime": "2030-01-01", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": "" + |}""".stripMargin + + feature("UKOB v3.1 POST /account-access-consents rejects invalid Permissions") { + + scenario("no account-read permission -> 400 with the OBP error code", + UKOpenBankingV310ConsentPermissions) { + val response = postAuthed( + body("""["ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits"]"""), + "account-access-consents") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(InvalidUKConsentPermissions) + } + + scenario("empty Permissions array -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed(body("[]"), "account-access-consents").code should equal(400) + } + + scenario("transaction depth without a direction -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadTransactionsBasic"]"""), + "account-access-consents").code should equal(400) + } + + scenario("a transaction direction without a depth -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadTransactionsCredits"]"""), + "account-access-consents").code should equal(400) + } + + scenario("unknown permission code -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadEverything"]"""), + "account-access-consents").code should equal(400) + } + + scenario("a valid combination is still created -> 201", UKOpenBankingV310ConsentPermissions) { + val response = postAuthed( + body("""["ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsCredits"]"""), + "account-access-consents") + response.code should equal(201) + (response.body \ "Data" \ "Permissions").extract[List[String]] should equal( + List("ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsCredits")) + } + } +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala new file mode 100644 index 0000000000..bc6379347d --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala @@ -0,0 +1,132 @@ +package code.api.UKOpenBanking.v4_0_1 + +import code.api.util.Consent +import code.api.util.ErrorMessages.InvalidUKConsentPermissions +import com.openbankproject.commons.model.ErrorMessage +import org.scalatest.Tag + +// The Account and Transaction API profile lists permission combinations the ASPSP "must reject +// with a 400 response code". OBP accepted all of them, so a consent could be created that no +// AISP can ever use: every AIS endpoint other than /accounts is /accounts/{AccountId}/..., and a +// consent with no account-read permission can never discover the ids it would need. Such a +// consent authorises AND returns an empty account list forever, with no error to explain it. +// +// Both the pure rule function and the HTTP surface are covered: the function is where the rules +// live, the endpoint is where the 400 has to actually come out. +class UKOpenBankingV401ConsentPermissionsTests extends UKOpenBankingV401ServerSetup { + + object UKOpenBankingV401ConsentPermissions extends Tag("UKOpenBankingV401ConsentPermissions") + + private def body(permissions: String): String = + s"""{ + | "Data": { + | "Permissions": $permissions, + | "ExpirationDateTime": "2030-01-01", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + + feature("Consent.validateUKConsentPermissions") { + + scenario("an empty array is refused", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions(Nil).isDefined should equal(true) + } + + scenario("a code that is not a UK permission code is refused", UKOpenBankingV401ConsentPermissions) { + val reason = Consent.validateUKConsentPermissions(List("ReadAccountsBasic", "ReadEverything")) + reason.isDefined should equal(true) + reason.get should include("ReadEverything") + } + + scenario("an array with no account-read permission is refused", UKOpenBankingV401ConsentPermissions) { + // The combination that motivated this work: authorises fine, then /aisp/accounts is empty + // forever because no account is readable. + val reason = Consent.validateUKConsentPermissions( + List("ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits")) + reason.isDefined should equal(true) + reason.get should include("ReadAccountsBasic") + } + + scenario("either account-read permission satisfies the requirement", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions(List("ReadAccountsBasic")) should equal(None) + Consent.validateUKConsentPermissions(List("ReadAccountsDetail")) should equal(None) + } + + scenario("transaction depth without a direction is refused", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsBasic")).isDefined should equal(true) + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsDetail")).isDefined should equal(true) + } + + scenario("a transaction direction without a depth is refused", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsCredits")).isDefined should equal(true) + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsDebits")).isDefined should equal(true) + } + + scenario("depth paired with either direction is accepted", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsBasic", "ReadTransactionsCredits")) should equal(None) + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsDetail", "ReadTransactionsDebits")) should equal(None) + } + + scenario("requesting both Basic and Detail is allowed, not rejected as duplication", + UKOpenBankingV401ConsentPermissions) { + // The profile calls this duplication but forbids rejecting on that basis alone. + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadAccountsDetail")) should equal(None) + Consent.validateUKConsentPermissions(List( + "ReadAccountsBasic", "ReadAccountsDetail", + "ReadTransactionsBasic", "ReadTransactionsDetail", + "ReadTransactionsCredits", "ReadTransactionsDebits")) should equal(None) + } + + scenario("permissions unrelated to the combination rules pass alongside a valid base", + UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadBalances", "ReadProducts", "ReadPAN")) should equal(None) + } + } + + feature("UKOB v4.0.1 POST /aisp/account-access-consents rejects invalid Permissions") { + + scenario("no account-read permission -> 400 with the OBP error code", + UKOpenBankingV401ConsentPermissions) { + val response = postAuthed( + body("""["ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits"]"""), + "aisp", "account-access-consents") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(InvalidUKConsentPermissions) + } + + scenario("empty Permissions array -> 400", UKOpenBankingV401ConsentPermissions) { + postAuthed(body("[]"), "aisp", "account-access-consents").code should equal(400) + } + + scenario("transaction depth without a direction -> 400", UKOpenBankingV401ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadTransactionsBasic"]"""), + "aisp", "account-access-consents").code should equal(400) + } + + scenario("unknown permission code -> 400", UKOpenBankingV401ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadEverything"]"""), + "aisp", "account-access-consents").code should equal(400) + } + + scenario("a valid combination is still created -> 201", UKOpenBankingV401ConsentPermissions) { + val response = postAuthed( + body("""["ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits"]"""), + "aisp", "account-access-consents") + response.code should equal(201) + (response.body \ "Data" \ "Permissions").extract[List[String]] should equal( + List("ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits")) + } + } +} From 9e895f63e802886f58dec57b35ac3080342105ab Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 01:34:28 +0200 Subject: [PATCH 36/63] fix: stop a UK consent reading accounts it no longer names grantUKConsentAccountAccess purged stale UK permission views only on the accounts the consent being authorised names. The one path that most needs purging is the one it skipped: when the PSU re-authorises and drops an account from the selection, nothing ever clears the rows the previous, wider consent left on that account. AccountAccess rows carry no consent identity, and User.hasAccountAccess only asks whether a (user, account, view, consumer) row exists -- never whether the account belongs to the consent presented on this request. So those leftovers keep answering for the new consent: a consent declaring one account was observed returning two from GET /aisp/accounts, with 200 on the details and transactions of the account it never declared. Sweep every account the PSU holds at this bank instead, and on an account this consent does not name revoke all seven UK permission views rather than only the undeclared ones. The grant pass still only touches the accounts the consent names, so nothing it is entitled to is lost. Cost: two live consents held by the same TPP for the same PSU now trim each other on the accounts they do not share. That is the limitation AccountAccess already had within a single account -- latest authorisation wins -- now applied across accounts, and under-granting is the right side to err on for a consent-scope check. The complete fix is a consent_id column on AccountAccess, tracked separately. Rejected: filtering at read time in hasAccountAccess. It would need the request's consent threaded through every call site and would still leave the stale rows in the table for any path that does not carry one. --- .../scala/code/api/util/ConsentUtil.scala | 44 ++++++++++++++----- ...UKOpenBankingV401ConsentScopingTests.scala | 32 ++++++++++++-- 2 files changed, 60 insertions(+), 16 deletions(-) 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 69408d37a4..5b1995bc86 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1367,17 +1367,34 @@ object Consent extends MdcLoggable { val jwtPayloadAsJson = compactRender(Extraction.decompose(updatedPayload)) val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) val jwt = CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret) - // Drop the UK permission views this consent does NOT declare, on the accounts it binds. - // grantAccessToViews only revokes-and-regrants the views named in the consent it is given, - // so without this a permission granted by an earlier, broader consent survives forever and - // silently widens every later one: after any consent covering ReadBalances, a subsequent - // ReadAccountsBasic-only consent would still read balances. The consent has to be - // authoritative for its own accounts, or narrowing it means nothing. + // Drop the UK permission views this consent does NOT declare, across every account the PSU + // holds at this bank. grantAccessToViews only revokes-and-regrants the views named in the + // consent it is given, so without this a permission granted by an earlier, broader consent + // survives forever and silently widens every later one: after any consent covering + // ReadBalances, a subsequent ReadAccountsBasic-only consent would still read balances. // - // Deliberately narrow: only the seven UK permission views, only on the accounts named in - // this consent, and only rows belonging to this consent's own consumer -- owner / - // ManageCustomViews come from account ownership, the *BerlinGroup views from the other - // standard, and another TPP's rows are that TPP's business, not this consent's. + // The sweep has to cover all held accounts, not just the ones this consent names: an + // account dropped from the selection at re-authorisation is exactly the case where nothing + // else would ever clear it. Rows carry no consent identity (see AccountAccess), and + // User.hasAccountAccess only asks whether a (user, account, view, consumer) row exists -- + // never whether the account belongs to the consent presented on this request -- so a row + // left behind by an earlier, wider consent is indistinguishable from one this consent + // granted. Sweeping only validatedAccountIds left those rows in place and let a consent + // read accounts it never declared. The consent has to be authoritative for the PSU's whole + // holding at this bank, or narrowing it means nothing. + // + // Deliberately narrow in the other two dimensions: only the seven UK permission views, and + // only rows belonging to this consent's own consumer -- owner / ManageCustomViews come from + // account ownership, the *BerlinGroup views from the other standard, and another TPP's rows + // are that TPP's business, not this consent's. + // + // Accepted cost: two live consents held by the same TPP for the same PSU now trim each + // other on the accounts they do not share -- authorising the second one revokes the first + // one's rows on accounts only the first names. That is the same limitation AccountAccess + // already has within a single account (rows carry no consent identity, so the latest + // authorisation wins), now applied across accounts. Erring towards under-granting is the + // right side to err on for a consent-scope check; the complete fix is a consent_id column + // on AccountAccess, which is tracked separately. // // The second pass sweeps the same views at ALL_CONSUMERS. Those rows can only have come // from a UK consent authorised before grants carried a consumer (account ownership never @@ -1400,8 +1417,10 @@ object Consent extends MdcLoggable { // it behaves exactly as it did before, rather than writing rows under an empty consumer id. val consentConsumerId = Option(consent.consumerId).map(_.trim).filterNot(_.isEmpty).getOrElse(Constant.ALL_CONSUMERS) + // notHeld above already guarantees boundAccountIds is a subset of heldAccountIds. + val boundAccountIds: Set[String] = validatedAccountIds.toSet for { - accountId <- validatedAccountIds + accountId <- heldAccountIds.toList viewId <- ukPermissionViewIds.toList } { val bankIdAccountIdViewId = BankIdAccountIdViewId(bankId, AccountId(accountId), ViewId(viewId)) @@ -1410,7 +1429,8 @@ object Consent extends MdcLoggable { // re-granted under this consumer immediately below, so nothing this consent is entitled to // is lost, and nothing it is not entitled to survives for another TPP to inherit. Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, Constant.ALL_CONSUMERS) - if (!permissions.contains(viewId)) { + // On an account this consent does not name, no UK permission view survives at all. + if (!boundAccountIds.contains(accountId) || !permissions.contains(viewId)) { Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, consentConsumerId) } } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala index 5065b5a561..9081eb51b5 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -38,7 +38,9 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup object UKConsentScoping extends Tag("UKConsentScoping") private val acc = testAccountId1.value + private val otherAcc = testAccountId0.value private val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + private val otherBankIdAccountId = BankIdAccountId(testBankId1, testAccountId0) private val ReadAccountsBasic = Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID private val ReadBalances = Constant.SYSTEM_READ_BALANCES_VIEW_ID @@ -50,7 +52,9 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup * Create a UK consent held by `consumerId` and run the authorise-time grant on it, i.e. the same * call the POST /consents/CONSENT_ID/authorise endpoint makes once SCA has passed. */ - private def authoriseConsentFor(consumerId: String, permissions: List[String]): Unit = { + private def authoriseConsentFor(consumerId: String, + permissions: List[String], + accountIds: List[String] = List(acc)): Unit = { val consentId = Consents.consentProvider.vend.saveUKConsent( user = Some(resourceUser1), bankId = None, @@ -74,16 +78,18 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup permissions.foreach(systemView) Await.result( - Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, List(acc), consent, None), + Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, accountIds, consent, None), 10.seconds) } /** Access as it is evaluated for a request arriving from `consumer` -- the consumer is what * User.hasAccountAccess keys its consumer-specific lookup on before falling back to ALL_CONSUMERS. */ - private def canRead(viewId: String, consumer: code.model.Consumer): Boolean = + private def canRead(viewId: String, + consumer: code.model.Consumer, + account: BankIdAccountId = bankIdAccountId): Boolean = UserExtended(resourceUser1).hasAccountAccess( systemView(viewId), - bankIdAccountId, + account, Some(CallContext(consumer = Full(consumer)))) feature("A UK consent is authoritative for the permissions it declares") { @@ -99,6 +105,24 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup } } + feature("A UK consent is authoritative for the accounts it names") { + scenario("re-authorising with fewer accounts drops the accounts left out", UKConsentScoping) { + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), + accountIds = List(acc, otherAcc)) + canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(true) + canRead(ReadBalances, testConsumer, otherBankIdAccountId) should equal(true) + + // Same TPP, same permissions, but the PSU dropped otherAcc from the selection this time. + // The account the consent no longer names must lose every UK permission view with it -- + // otherwise the consent reads accounts it never declared. + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), + accountIds = List(acc)) + canRead(ReadAccountsBasic, testConsumer) should equal(true) + canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(false) + canRead(ReadBalances, testConsumer, otherBankIdAccountId) should equal(false) + } + } + feature("One TPP's UK consent does not rewrite another TPP's access") { scenario("a second consumer authorising a narrower consent leaves the first consumer's access intact", UKConsentScoping) { authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) From c938cd7b85497d40bbacb72ad7a5a20c9d6c527d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 12:07:19 +0200 Subject: [PATCH 37/63] test: pin the half of UK consent account scoping that is still open Narrowing a UK consent by account now holds when the PSU re-authorises, but not when the same TPP keeps a wider consent alive alongside the narrower one: both resolve to the same (user, account, view, consumer) rows, so re-authorising the wider one re-grants an account the narrower one never named. That limitation lived only in a status file, where nothing checks it. Pin it as a characterisation test instead, asserting what the code does today rather than what it should do, with the reasoning in the comments. When AccountAccess carries a consent_id the assertion becomes false and this scenario fails -- that failure is the signal to flip it, which is the whole point of writing it down here. --- ...UKOpenBankingV401ConsentScopingTests.scala | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala index 9081eb51b5..845076ff50 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -24,9 +24,14 @@ import scala.concurrent.duration._ * to the authorising PSU, so every UK consent for that PSU lands on the same (user, account, view) * rows -- and those rows carry no consent identity at all. * - * That makes two properties worth pinning down, neither of which any existing suite covers: - * - narrowing: re-authorising with fewer permissions must actually drop the ones left out; - * - isolation: one TPP's authorisation must not rewrite another TPP's access to the same account. + * That makes three properties worth pinning down, none of which any existing suite covers: + * - narrowing: re-authorising with fewer permissions -- or fewer accounts -- must actually drop + * what was left out; + * - isolation: one TPP's authorisation must not rewrite another TPP's access to the same account; + * - and the one place narrowing still cannot hold: two consents live at once under the SAME TPP, + * which share a single set of rows. That is pinned as a characterisation test at the bottom of + * this file rather than left unstated -- it is the remaining half of the same gap, and it only + * closes when AccountAccess carries a consent_id. * * Asserted at the UserExtended.hasAccountAccess layer because that is exactly what * APIUtil.checkViewAccessAndReturnView -- and therefore every UK data endpoint -- consults. The @@ -54,7 +59,7 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup */ private def authoriseConsentFor(consumerId: String, permissions: List[String], - accountIds: List[String] = List(acc)): Unit = { + accountIds: List[String] = List(acc)): String = { val consentId = Consents.consentProvider.vend.saveUKConsent( user = Some(resourceUser1), bankId = None, @@ -80,6 +85,17 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup Await.result( Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, accountIds, consent, None), 10.seconds) + consentId + } + + /** Re-run the authorise-time grant on a consent that already exists and is still live -- the PSU + * re-authenticating against a consent the TPP never revoked. */ + private def reAuthorise(consentId: String, accountIds: List[String]): Unit = { + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException(s"consent $consentId not found") + Await.result( + Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, accountIds, consent, None), + 10.seconds) } /** Access as it is evaluated for a request arriving from `consumer` -- the consumer is what @@ -123,6 +139,34 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup } } + /** + * Characterisation test: this pins behaviour that is WRONG but currently accepted, so that the + * limitation is visible in the suite rather than only in a status file. When AccountAccess grows + * a consent_id column the assertions below become false and this scenario fails -- that failure + * is the signal to flip them to the values the comments name, not to relax them. + */ + feature("KNOWN LIMITATION: two live consents held by the same TPP share one set of rows") { + scenario("re-authorising the wider consent widens a narrower live one", UKConsentScoping) { + val wide = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc, otherAcc)) + val narrow = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc)) + + // The fix this suite pins: authorising `narrow` drops the account it does not name. + canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(false) + + // But `wide` was never revoked, and the PSU re-authenticating against it re-grants otherAcc. + // Both consents are live and both resolve to the same (user, account, view, consumer) rows, + // so there is no answer that is right for both: whichever was authorised last wins. + reAuthorise(wide, List(acc, otherAcc)) + + // Correct for `wide`. WRONG for `narrow`, which still names only `acc` -- with a consent_id + // column this stays false when the request presents `narrow`. Asserted true because that is + // what the code does today, and pretending otherwise would hide the hole. + canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(true) + } + } + feature("One TPP's UK consent does not rewrite another TPP's access") { scenario("a second consumer authorising a narrower consent leaves the first consumer's access intact", UKConsentScoping) { authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) From 01826cedef4cbe630c150fd16711764064298af2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 13:34:07 +0200 Subject: [PATCH 38/63] fix: give a UK consent a principal of its own A UK consent's access was written to AccountAccess rows keyed on the real PSU. Those rows carry no consent identity, so every consent that PSU had granted shared one set of them and rewrote each other: authorising a second consent narrowed the first, and a consent could read accounts it had never named. The sweep added to contain that only made the shared rows tidier -- it could not make them belong to anyone. Berlin Group and OBP-native never had the problem. Their consent JWTs carry a random UUID in `sub`, which applyConsentRules resolves to a user that exists only for that consent and grants the JWT's views to. UK consents have always carried the same random `sub`; it was simply never used. Use it. The isolation stops being something to enforce and becomes what the data model says: one consent, one principal, its own rows. Both credential paths resolve it. The Consent-Id header path does so in applyUKRules; the Bearer path -- where the request is authenticated as the PSU long before checkUKConsent runs -- does so in a hook at the end of authentication that can only narrow the principal, never widen it, and falls back to today's behaviour on anything unexpected. The PSU is kept on the CallContext as `consenter`, and checkUKConsent's ownership check now compares against that, so "only the PSU this consent belongs to may use it" is unchanged. Three things follow, and are the reason this is worth the churn: The bulk endpoints stop leaking. GET /aisp/balances and its v3.1 twin list accounts with a bare `WHERE user_id = ?` and never check a view, so they answered for every account the PSU held whatever the consent said. The query needs no consent awareness now that the identity has it. The missing ReadBalances check is added anyway, and the two bulk /transactions endpoints move off the owner view, which a principal that owns nothing cannot hold -- and whose absence getOrElse(Nil) would have turned into a silent empty result. Firehose and ABAC stop applying. Both are consulted before the AccountAccess lookup and both key on entitlements; a UK consent JWT carries none, so its principal has none. No new guard on the shared access-control path was needed to get this. Revocation starts meaning something. It used to flip a status column and leave the granted rows live in the table forever. Rows now belong to exactly one consent, so revoke and expiry can drop them. grantUKConsentAccountAccess is left holding the account-holdership check and the JWT rewrite -- it grants nothing, and must keep running as the real PSU, since only a PSU holds accounts. grantAccessToViews reconciles instead of revoking and re-granting: running per request, the old shape let one request delete the row another had just written and 403 itself, or collide on the unique index. That was a live defect for Berlin Group too. Costs, stated plainly. Consent traffic now records the human on metric rows rather than the principal, which changes what Berlin Group and OBP-native rows have always held (a per-consent UUID and an empty username) -- an improvement, but a change. Consent principals are hidden from GET /users, where they had no business being. And a UK consent authorised before account binding existed still carries placeholder views naming no account; it keeps running as the PSU, with a warning, rather than silently losing all access. --- .../v3_1_0/Http4sUKOBv310Balances.scala | 12 +- .../v3_1_0/Http4sUKOBv310Transactions.scala | 10 +- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 23 +- .../main/scala/code/api/util/APIUtil.scala | 2 + .../main/scala/code/api/util/ApiSession.scala | 27 +- .../scala/code/api/util/ConsentUtil.scala | 288 ++++++++++++------ .../scala/code/consent/MappedConsent.scala | 20 +- .../code/scheduler/ConsentScheduler.scala | 24 +- .../src/main/scala/code/users/LiftUsers.scala | 9 +- .../main/scala/code/views/MapperViews.scala | 7 + obp-api/src/main/scala/code/views/Views.scala | 4 + .../UKOpenBankingV401AccountInfoTests.scala | 25 +- ...UKOpenBankingV401ConsentScopingTests.scala | 222 ++++++++------ 13 files changed, 461 insertions(+), 212 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala index 1fb4eeae35..80826b6686 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala @@ -136,12 +136,22 @@ object Http4sUKOBv310Balances extends MdcLoggable { lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV31Prefix` / "balances" => EndpointHelpers.withUser(req) { (u, cc) => + val balancesViewId = ViewId(Constant.SYSTEM_READ_BALANCES_VIEW_ID) for { _ <- NewStyle.function.checkUKConsent(u, Some(cc)) _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) - } yield JSONFactory_UKOpenBanking_310.createBalancesJSON(accounts) + } yield { + // See the v4.0.1 twin: holding some view on an account does not make its balance readable, + // and this endpoint used to answer for every account the caller could see regardless of + // what the consent asked for. Filter on the same view the per-account endpoint checks. + val readable = accounts.filter { account => + code.api.util.APIUtil.checkViewAccessAndReturnView( + balancesViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)).isDefined + } + JSONFactory_UKOpenBanking_310.createBalancesJSON(readable) + } } } resourceDocs += ResourceDoc( diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala index 6b83e17911..bb406c8af5 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala @@ -581,9 +581,17 @@ object Http4sUKOBv310Transactions extends MdcLoggable { availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) allTxns <- Future { + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) accounts.flatMap { bankAccount => (for { - view <- UserExtended(u).checkOwnerViewAccessAndReturnOwnerView(BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc)) + // The owner view, which this used to gate on, comes from holding the account and so + // says nothing about what a consent permits -- it let a consent that never asked for + // transactions read them, and moderated them as the owner rather than as the granted + // view. Gate on the transaction views the consent actually grants, exactly as the + // per-account endpoint above does. + view <- code.api.util.APIUtil.checkViewAccessAndReturnView(detailViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc)) + .or(code.api.util.APIUtil.checkViewAccessAndReturnView(basicViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc))) params = createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))).getOrElse(Nil) (transactions, _) <- BankAccountExtended(bankAccount).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc), params) } yield transactions).getOrElse(Nil) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index c088d27e7d..3bcf7778ce 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -2369,12 +2369,23 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "balances" => EndpointHelpers.withUser(req) { (u, cc) => + val balancesViewId = ViewId(Constant.SYSTEM_READ_BALANCES_VIEW_ID) for { _ <- NewStyle.function.checkUKConsent(u, Some(cc)) _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) - } yield JSONFactory_UKOpenBanking_401.createBalancesJSON(accounts) + } yield { + // Holding some view on an account is not the same as being allowed to read its balance: + // ReadBalances is a permission a consent may simply never have asked for. The per-account + // balances endpoint checks it, this one did not -- so it answered for every account the + // caller could see, whatever the consent said. Filter on the same view it does. + val readable = accounts.filter { account => + APIUtil.checkViewAccessAndReturnView( + balancesViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)).isDefined + } + JSONFactory_UKOpenBanking_401.createBalancesJSON(readable) + } } } resourceDocs += ResourceDoc( @@ -3519,9 +3530,17 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) allTxns <- Future { + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) accounts.flatMap { bankAccount => (for { - view <- UserExtended(u).checkOwnerViewAccessAndReturnOwnerView(BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc)) + // The owner view, which this used to gate on, comes from holding the account and so + // says nothing about what a consent permits -- it let a consent that never asked for + // transactions read them, and moderated them as the owner rather than as the granted + // view. Gate on the transaction views the consent actually grants, exactly as the + // per-account endpoint does. + view <- APIUtil.checkViewAccessAndReturnView(detailViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc)) + .or(APIUtil.checkViewAccessAndReturnView(basicViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc))) params = createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))).getOrElse(Nil) (transactions, _) <- BankAccountExtended(bankAccount).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc), params) } yield transactions).getOrElse(Nil) 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 58f77c38dd..95fbee0c0d 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2971,6 +2971,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ x => (x._1, x._2.map(_.copy(httpBody = body.toOption))) } map { // Inject logged in user into CallContext data x => (x._1, x._2.map(_.copy(user = x._1))) + } map { // A UK Open Banking consent presented as a token claim runs as that consent's shadow user + x => Consent.applyUKConsentPrincipalFromToken(x._1, x._2) } } diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 7f6aa202b0..f8c20b9d8e 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -77,16 +77,29 @@ case class CallContext( override def toString: String = SecureLogging.maskSensitive( s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})" ) + /** + * The human being this request is on behalf of, where one is known. + * + * `user` is not always a person: a consent resolves to a shadow user that exists only for that + * consent (Berlin Group, OBP-native, and -- since UK consents moved to the same model -- UK too). + * Anything that must name a human rather than a principal reads this instead: the CBS adapter, + * which tells the core banking system who is asking, and metric attribution. + */ + def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user) + //This is only used to connect the back adapter. not useful for sandbox mode. def toOutboundAdapterCallContext: OutboundAdapterCallContext= { for{ user <- this.user //If there is no user, then will go to `.openOr` method, to return anonymousAccess box. - username <- tryo(Some(user.name)) - currentResourceUserId <- tryo(Some(user.userId)) + // The adapter is told which human is asking. A shadow user has no name and no customer links, + // so sending it would make every consent-borne request look like a different, unknown caller. + psu <- this.humanUser + username <- tryo(Some(psu.name)) + currentResourceUserId <- tryo(Some(psu.userId)) consumerId = this.consumer.map(_.consumerId.get).openOr("") // if none, just return "" permission <- Views.views.vend.getPermissionForUser(user) views <- tryo(permission.views) - linkedCustomers <- tryo(CustomerX.customerProvider.vend.getCustomersByUserId(user.userId)) + linkedCustomers <- tryo(CustomerX.customerProvider.vend.getCustomersByUserId(psu.userId)) likedCustomersBasic = if (linkedCustomers.isEmpty) None else Some(createInternalLinkedBasicCustomersJson(linkedCustomers)) userAuthContexts<- UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(user.userId) basicUserAuthContextsFromDatabase = if (userAuthContexts.isEmpty) None else Some(createBasicUserAuthContextJson(userAuthContexts)) @@ -135,8 +148,12 @@ case class CallContext( CallContextLight( gatewayLoginRequestPayload = this.gatewayLoginRequestPayload, gatewayLoginResponseHeader = this.gatewayLoginResponseHeader, - userId = this.user.map(_.userId).toOption, - userName = this.user.map(_.name).toOption, + // Metrics name the human, not the principal. A consent's shadow user would record a per-consent + // UUID and an empty username, which is what Berlin Group and OBP-native traffic has always + // looked like on the metrics table; the consent itself stays identifiable via + // consentReferenceId below. + userId = this.humanUser.map(_.userId).toOption, + userName = this.humanUser.map(_.name).toOption, consumerId = this.consumer.map(_.consumerId.get).toOption, appName = this.consumer.map(_.name.get).toOption, developerEmail = this.consumer.map(_.developerEmail.get).toOption, 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 5b1995bc86..ea796f1f93 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -402,19 +402,35 @@ object Consent extends MdcLoggable { */ private def grantAccessToViews(user: User, consent: ConsentJWT, consumerId: String = Constant.ALL_CONSUMERS): Box[User] = { val isConsumerScoped = consumerId != Constant.ALL_CONSUMERS + val wanted: List[BankIdAccountIdViewId] = consent.views.map { view => + BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id), ViewId(view.view_id)) + }.distinct + + // Reconcile rather than revoke-then-regrant. This runs on every request that presents the + // consent, so deleting a row and putting it back left a window in which the row did not exist: + // a second concurrent request for the same consent would delete the row the first had just + // re-granted, and the first would then fail its own access check with a 403 it could do nothing + // about. Two requests could also insert the same row at once and collide on the unique index. + // Touching only the difference means a steady-state request writes nothing at all. + val held: List[BankIdAccountIdViewId] = Views.views.vend.accessGrantedToUserForConsumer(user, consumerId) + val wantedSet = wanted.toSet for { - view <- consent.views + staleAccess <- held.filterNot(wantedSet.contains) } yield { - val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) - if (isConsumerScoped) Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, consumerId) - else Views.views.vend.revokeAccess(bankIdAccountIdViewId, user) + if (isConsumerScoped) Views.views.vend.revokeAccessToViewForUserAndConsumer(staleAccess, user, consumerId) + else Views.views.vend.revokeAccess(staleAccess, user) } + + val heldSet = held.toSet val result: List[Box[View]] = { for { view <- consent.views } yield { val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) - Views.views.vend.systemView(ViewId(view.view_id)) match { + if (heldSet.contains(bankIdAccountIdViewId)) { + // Already granted and still wanted -- leave the row alone. + Views.views.vend.systemView(ViewId(view.view_id)).or(Views.views.vend.customView(ViewId(view.view_id), BankIdAccountId(BankId(view.bank_id), AccountId(view.account_id)))) + } else Views.views.vend.systemView(ViewId(view.view_id)) match { case Full(systemView) => if (isConsumerScoped) Views.views.vend.grantAccessToSystemViewForConsumer(BankId(view.bank_id), AccountId(view.account_id), systemView, user, consumerId) @@ -773,9 +789,8 @@ object Consent extends MdcLoggable { * already verified the access token's signature before checkUKConsent runs; here the caller may * hand us the consent JWT itself, so it is verified against the consent's stored secret. * - * Deliberately does NOT reuse applyConsentRules: that resolves the principal with - * getOrCreateUser(consent.sub, ...), and createUKConsentJWT sets sub to a fresh UUID rather than - * the PSU -- reusing it would mint a phantom user and grant it the consent's views. + * The principal is the consent's own shadow user (see resolveUKConsentShadowUser); the PSU the + * consent belongs to is carried alongside it on the CallContext as `consenter`. */ def applyUKRules(storedConsent: MappedConsent, consentHeaderValue: String, @@ -815,10 +830,13 @@ object Consent extends MdcLoggable { // The PSU bound to the consent by updateConsentUser during the authorise ceremony. A // consent that was never authorised has no user, and the status gate above already // rejected it -- this is the belt to that braces. - user <- Users.users.vend.getUserByUserId(storedConsent.userId) ?~! ErrorMessages.ConsentNotFound + psu <- Users.users.vend.getUserByUserId(storedConsent.userId) ?~! ErrorMessages.ConsentNotFound + principal <- resolveUKConsentPrincipal(storedConsent, consentJwt, psu) } yield { - (user, callContext.copy( - consenter = Full(user), + (principal, callContext.copy( + // The PSU stays reachable for everything that needs a human: the CBS adapter, metric + // attribution, and CallContext.effectiveHumanUserId. + consenter = Full(psu), ukConsentId = Some(storedConsent.consentId), consentReferenceId = Some(storedConsent.consentReferenceId) )) @@ -832,6 +850,151 @@ object Consent extends MdcLoggable { } } + /** + * Drop the account access a consent's shadow user holds. + * + * Revoking or expiring a consent only ever flipped a status column, which was enough while the + * status gate was the only thing standing between the caller and the data. It leaves the granted + * AccountAccess rows in the table forever, so anything that reads them without going through the + * consent gate -- the account-permissions listing, a future endpoint, an operator looking at the + * database -- still sees a revoked consent's access as live. A shadow user's rows belong to + * exactly one consent, so for the first time they can be cleaned up without guessing. + * + * Safe for a consent whose shadow user was never minted (one that predates account binding and + * still runs as the PSU): the lookup simply finds nothing. It must never fall back to the PSU -- + * that would delete access the PSU holds in their own right. + */ + def revokeConsentAccountAccess(consent: code.consent.ConsentTrait): Unit = { + implicit val dateFormats = CustomJsonFormats.formats + val revoked = for { + consentJwt <- JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map(parse(_).extract[ConsentJWT]) + shadowUser <- Users.users.vend.getUserByProviderId(provider = consentJwt.iss, idGivenByProvider = consentJwt.sub) + } yield { + Views.views.vend.accessGrantedToUserForConsumer(shadowUser, Constant.ALL_CONSUMERS).map { access => + Views.views.vend.revokeAccessToViewForUserAndConsumer(access, shadowUser, Constant.ALL_CONSUMERS) + }.size + } + revoked match { + case Full(count) if count > 0 => + logger.info(s"revokeConsentAccountAccess: dropped $count account access rows for consent ${consent.consentId}") + case _ => + } + } + + /** + * The Bearer-token half of the shadow-user resolution. + * + * A UK consent normally travels as a `consent_id` claim inside an OAuth2 access token. On that + * path the request is authenticated as the PSU long before any UK code runs, and checkUKConsent -- + * which validates the consent -- executes inside the endpoint, by which point the principal is + * already fixed. So the swap has to happen here, at the end of authentication, where the + * CallContext the endpoint will see is still being assembled. + * + * This performs no validation and cannot widen anything: it either narrows the principal to the + * consent's shadow user or leaves the request exactly as it was. checkUKConsent still runs + * afterwards and still rejects a consent that is the wrong standard, revoked, expired, bound to a + * different PSU, or held by a different consumer -- with the same status codes as before. + */ + def applyUKConsentPrincipalFromToken(user: Box[User], + callContext: Option[CallContext]): (Box[User], Option[CallContext]) = { + def swap: Option[(Box[User], Option[CallContext])] = for { + cc <- callContext + // The Consent-Id / Consent-JWT header path resolved the principal already, in applyUKRules. + if cc.ukConsentId.isEmpty + psu <- user.toOption + accessToken <- cc.authReqHeaderField.toOption.map(_.replaceFirst("Bearer\\s+", "").trim) + if JwtUtil.checkIfStringIsJWTValue(accessToken).isDefined + consentId <- JwtUtil.getOptionalClaim("consent_id", accessToken) + storedConsent <- Consents.consentProvider.vend.getConsentByConsentId(consentId).toOption + if storedConsent.apiStandard == ConsentStandardUK + consentJwt <- { + implicit val dateFormats = CustomJsonFormats.formats + JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken).map(parse(_).extract[ConsentJWT]).toOption + } + principal <- resolveUKConsentPrincipal(storedConsent, consentJwt, psu).toOption + } yield { + (Full(principal), Some(cc.copy( + user = Full(principal), + consenter = Full(psu), + consentReferenceId = Some(storedConsent.consentReferenceId) + ))) + } + // Anything unexpected -- an unparseable token, a consent row that has gone -- leaves the request + // as it is rather than failing authentication for a reason the caller cannot act on. + scala.util.Try(swap).toOption.flatten.getOrElse((user, callContext)) + } + + /** + * The user a UK consent's data access runs as: the consent's own shadow user. + * + * Every consent JWT carries a random UUID in `sub` (createUKConsentJWT, same as Berlin Group and + * OBP-native). Berlin Group and OBP-native resolve that UUID to a user of its own and grant it the + * consent's views, so one consent's AccountAccess rows can never be another's. UK used to skip + * that and grant to the real PSU instead, which is why two consents held by the same TPP for the + * same PSU wrote to one set of rows and rewrote each other -- and why a consent could read + * accounts it never named. Resolving to the shadow user makes the isolation structural: the rows + * belong to an identity that exists only for this consent. + * + * It also closes the bypasses for free. The shadow user holds no entitlements (a UK consent JWT + * carries `entitlements = Nil`), so account firehose and the ABAC fallback -- both of which are + * consulted before the AccountAccess check in APIUtil.hasAccountAccess -- can never answer for it. + * And it holds no `owner` view, so the account-listing query (a bare WHERE user_id = ?) returns + * exactly the consent's accounts without the query having to learn about consents at all. + * + * The views are re-granted from the JWT on every request, as Berlin Group does: the JWT is the + * source of truth, and the rows are only its materialisation. Narrowing a consent therefore takes + * effect immediately, with no sweep of anything. + * + * Grandfathering: a consent authorised before grantUKConsentAccountAccess existed still carries + * the `(null, null, permission)` placeholder views createUKConsentJWT writes at creation time. + * Those name no account, so a shadow user would be granted nothing and every request would 403. + * Such a consent had no enforcement to begin with, so it keeps running as the PSU -- the status + * quo, logged so it is visible. + */ + private def resolveUKConsentPrincipal(storedConsent: MappedConsent, consentJwt: ConsentJWT, psu: User): Box[User] = { + val namesRealAccounts = consentJwt.views.exists { view => + Option(view.bank_id).exists(_.trim.nonEmpty) && Option(view.account_id).exists(_.trim.nonEmpty) + } + if (!namesRealAccounts) { + logger.warn( + s"UK consent ${storedConsent.consentId} names no real account in its JWT views -- it predates " + + s"account binding. Falling back to the PSU, which means this consent's declared Permissions " + + s"place no limit on what it can read. Re-authorise it to bind it to accounts.") + Full(psu) + } else { + for { + shadowUser <- getOrCreateUKConsentShadowUser(storedConsent, consentJwt) + _ <- grantAccessToViews(shadowUser, consentJwt) + } yield shadowUser + } + } + + /** + * Get (or first mint) the shadow user for a UK consent, copying the consent's snapshot of the + * PSU's auth context onto it the first time -- the connectors read those key/value pairs to + * identify the customer, so a shadow user without them would be a different caller to the CBS. + */ + private def getOrCreateUKConsentShadowUser(storedConsent: MappedConsent, consentJwt: ConsentJWT): Box[User] = { + Users.users.vend.getUserByProviderId(provider = consentJwt.iss, idGivenByProvider = consentJwt.sub) match { + case Full(existing) => Full(existing) + case _ => + for { + created <- Users.users.vend.createResourceUser( + provider = consentJwt.iss, + providerId = Some(consentJwt.sub), + createdByConsentId = Some(storedConsent.consentId), + name = None, + email = None, + userId = None, + createdByUserInvitationId = None, + company = None, + lastMarketingAgreementSignedDate = None + ) ?~! ErrorMessages.CannotGetOrCreateUser + _ = copyAuthContextOfConsentToUser(storedConsent.consentId, created.userId, newUser = true) + } yield created + } + } + def applyRulesOldStyle(consentId: Option[String], callContext: CallContext): (Box[User], CallContext) = { val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false) (consentId, allowed) match { @@ -1320,8 +1483,13 @@ object Consent extends MdcLoggable { * bank_id/account_id, no wildcard). Call this once the PSU has selected which accounts the * consent applies to (currently: the UK authorise step, since OBP has no separate * ASPSP-hosted account-selection UI) to replace those dead rows with real per-account - * ConsentViews, and to eagerly grant the corresponding AccountAccess rows — mirroring how - * updateViewsOfBerlinGroupConsentJWT resolves BG's IBAN-keyed access into real accounts. + * ConsentViews — mirroring how updateViewsOfBerlinGroupConsentJWT resolves BG's IBAN-keyed access + * into real accounts. + * + * The JWT it writes is the consent's whole scope: resolveUKConsentPrincipal re-derives the + * consent's AccountAccess from it on every request. So this grants nothing itself, and must keep + * running as the real PSU -- the account-holdership check below is the security control that stops + * a consent being bound to somebody else's accounts, and only the PSU holds accounts. */ def grantUKConsentAccountAccess(user: User, bankId: BankId, @@ -1367,87 +1535,15 @@ object Consent extends MdcLoggable { val jwtPayloadAsJson = compactRender(Extraction.decompose(updatedPayload)) val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) val jwt = CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret) - // Drop the UK permission views this consent does NOT declare, across every account the PSU - // holds at this bank. grantAccessToViews only revokes-and-regrants the views named in the - // consent it is given, so without this a permission granted by an earlier, broader consent - // survives forever and silently widens every later one: after any consent covering - // ReadBalances, a subsequent ReadAccountsBasic-only consent would still read balances. - // - // The sweep has to cover all held accounts, not just the ones this consent names: an - // account dropped from the selection at re-authorisation is exactly the case where nothing - // else would ever clear it. Rows carry no consent identity (see AccountAccess), and - // User.hasAccountAccess only asks whether a (user, account, view, consumer) row exists -- - // never whether the account belongs to the consent presented on this request -- so a row - // left behind by an earlier, wider consent is indistinguishable from one this consent - // granted. Sweeping only validatedAccountIds left those rows in place and let a consent - // read accounts it never declared. The consent has to be authoritative for the PSU's whole - // holding at this bank, or narrowing it means nothing. - // - // Deliberately narrow in the other two dimensions: only the seven UK permission views, and - // only rows belonging to this consent's own consumer -- owner / ManageCustomViews come from - // account ownership, the *BerlinGroup views from the other standard, and another TPP's rows - // are that TPP's business, not this consent's. - // - // Accepted cost: two live consents held by the same TPP for the same PSU now trim each - // other on the accounts they do not share -- authorising the second one revokes the first - // one's rows on accounts only the first names. That is the same limitation AccountAccess - // already has within a single account (rows carry no consent identity, so the latest - // authorisation wins), now applied across accounts. Erring towards under-granting is the - // right side to err on for a consent-scope check; the complete fix is a consent_id column - // on AccountAccess, which is tracked separately. + // Writing the JWT is the whole job. Nothing is granted here. // - // The second pass sweeps the same views at ALL_CONSUMERS. Those rows can only have come - // from a UK consent authorised before grants carried a consumer (account ownership never - // grants these seven -- see LocalMappedConnector's viewsToGenerate), and leaving them would - // silently defeat the whole fix: User.hasAccountAccess falls back to ALL_CONSUMERS when it - // finds no consumer-specific row, so a pre-existing ReadBalances row would still answer for - // a consent that never asked for balances. Each account heals the first time it is - // re-authorised; the cost is that a TPP whose access predates this change has to - // re-authorise to get its own scoped rows back. - val ukPermissionViewIds: Set[String] = Set( - Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID, - Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID, - Constant.SYSTEM_READ_BALANCES_VIEW_ID, - Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID, - Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID, - Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, - Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID - ) - // A consent without a consumer cannot own rows of its own; fall back to the shared scope so - // it behaves exactly as it did before, rather than writing rows under an empty consumer id. - val consentConsumerId = - Option(consent.consumerId).map(_.trim).filterNot(_.isEmpty).getOrElse(Constant.ALL_CONSUMERS) - // notHeld above already guarantees boundAccountIds is a subset of heldAccountIds. - val boundAccountIds: Set[String] = validatedAccountIds.toSet - for { - accountId <- heldAccountIds.toList - viewId <- ukPermissionViewIds.toList - } { - val bankIdAccountIdViewId = BankIdAccountIdViewId(bankId, AccountId(accountId), ViewId(viewId)) - // Idempotent: a view the PSU never held simply reports CannotFindAccountAccess. - // Legacy shared rows go regardless of what this consent declares -- the declared ones are - // re-granted under this consumer immediately below, so nothing this consent is entitled to - // is lost, and nothing it is not entitled to survives for another TPP to inherit. - Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, Constant.ALL_CONSUMERS) - // On an account this consent does not name, no UK permission view survives at all. - if (!boundAccountIds.contains(accountId) || !permissions.contains(viewId)) { - Views.views.vend.revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId, user, consentConsumerId) - } - } - - // Eagerly grant real AccountAccess now: UK consents are exercised via an opaque OAuth2 - // Bearer token (checkUKConsent), not the Consent-JWT header BG/OBP consents use to - // lazily re-derive access on every call — so the grant has to happen once, here. - // - // Scoped to this consent's consumer, so the rows are this TPP's alone: another TPP's - // consent over the same account neither reads nor rewrites them. - updatedPayload.foreach { consentJwt => - grantAccessToViews(user, consentJwt, consentConsumerId) match { - case Failure(msg, _, _) => - logger.warn(s"grantUKConsentAccountAccess: grantAccessToViews reported: $msg") - case _ => - } - } + // This used to eagerly write AccountAccess rows for the PSU, and then sweep away the rows + // an earlier, wider consent had left behind -- necessary only because those rows were keyed + // to the PSU and so were shared by every consent that PSU had granted. Data access now runs + // as the consent's own shadow user (resolveUKConsentPrincipal), which re-derives its views + // from this JWT on every request, so a consent's rows are its own and narrowing one takes + // effect the moment this JWT is written. There is nothing left to sweep, and an eager grant + // to the PSU would only put back the rows that caused the problem. Consents.consentProvider.vend.setJsonWebToken(consent.consentId, jwt) } } @@ -1555,7 +1651,11 @@ object Consent extends MdcLoggable { // interval; this reactive check closes the gap immediately regardless of timing. case currentTimeMillis if Option(c.expirationDateTime).exists(_.getTime < currentTimeMillis) => Failure(ErrorMessages.ConsentExpiredIssue) - case _ if c.mUserId.get != user.userId => + // The consent must belong to the PSU the access token authenticated. Data access runs + // as the consent's shadow user (applyUKConsentPrincipalFromToken), so compare against + // the PSU that swap set aside rather than against the principal -- a shadow user's id + // can never equal mUserId. `user` is the fallback for a request the swap left alone. + case _ if c.mUserId.get != calContext.flatMap(_.consenter.toOption).getOrElse(user).userId => Failure(ErrorMessages.ConsentDoesNotMatchUser) case _ => val consumerIdOfLoggedInUser: Option[String] = calContext.flatMap(_.consumer.map(_.consumerId.get)) diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 960cb731a3..c281d3103c 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -373,7 +373,13 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo // already revoked makes this a 0-row no-op, so we never resurrect or double-revoke. val rows = code.bankconnectors.DoobieConsentStatusQueries .conditionalRevoke(consent.id.get, ConsentStatus.REVOKED.toString) - if (rows == 1) MappedConsent.find(By(MappedConsent.mConsentId, consentId)) + if (rows == 1) { + // Every revoke endpoint funnels through here, so this is the one place that has to give + // the granted access back. The status flip alone leaves the AccountAccess rows live in + // the table for anything that reads them without asking the consent first. + code.api.util.Consent.revokeConsentAccountAccess(consent) + MappedConsent.find(By(MappedConsent.mConsentId, consentId)) + } else Failure(ErrorMessages.ConsentAlreadyRevoked) case Empty => Empty ?~! ErrorMessages.ConsentNotFound @@ -388,10 +394,14 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo case Full(consent) if consent.status == ConsentStatus.terminatedByTpp.toString => Failure(ErrorMessages.ConsentAlreadyRevoked) case Full(consent) => - tryo(consent - .mStatus(ConsentStatus.terminatedByTpp.toString) - .mLastActionDate(now) - .saveMe()) + tryo { + val terminated = consent + .mStatus(ConsentStatus.terminatedByTpp.toString) + .mLastActionDate(now) + .saveMe() + code.api.util.Consent.revokeConsentAccountAccess(terminated) + terminated + } case Empty => Empty ?~! ErrorMessages.ConsentNotFound case Failure(msg, _, _) => diff --git a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index 6f9dd17aa5..5ad7405406 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -87,7 +87,11 @@ object ConsentScheduler extends MdcLoggable { newStatus = ConsentStatus.rejected.toString, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) @@ -127,7 +131,11 @@ object ConsentScheduler extends MdcLoggable { consentPrimaryKey = consent.id.get, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) @@ -161,7 +169,11 @@ object ConsentScheduler extends MdcLoggable { newStatus = ConsentStatus.EXPIRED.toString, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for OBP consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) @@ -197,7 +209,11 @@ object ConsentScheduler extends MdcLoggable { newStatus = ConsentStatus.EXPIRED.toString, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for UK consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index f117f9f110..ee2bc891cc 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -179,7 +179,14 @@ object LiftUsers extends Users with MdcLoggable{ val optionalParams: Seq[QueryParam[ResourceUser]] = Seq(limit.toSeq, offset.toSeq, deleted.toSeq).flatten - def getAllResourceUsers(): List[ResourceUser] = ResourceUser.findAll(optionalParams: _*) + // Users a consent minted for itself are not people and do not belong in a list of people: they + // have no username and no email, there is one of them for every consent ever granted, and they + // outnumber real users by orders of magnitude on any busy instance. They stay reachable by id + // and through the account-access data; they just do not pad out this list. + def isConsentPrincipal(user: ResourceUser): Boolean = + Option(user.CreatedByConsentId.get).exists(_.trim.nonEmpty) + + def getAllResourceUsers(): List[ResourceUser] = ResourceUser.findAll(optionalParams: _*).filterNot(isConsentPrincipal) val showUsers: List[ResourceUser] = locked.map(_.toLowerCase()) match { case Some("active") => diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 7f1669c009..4f90ed8295 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -388,6 +388,13 @@ object MapperViews extends Views with MdcLoggable { isRevokedCustomViewAccess or isRevokedSystemViewAccess } + def accessGrantedToUserForConsumer(user: User, consumerId: String): List[BankIdAccountIdViewId] = { + AccountAccess.findAll( + By(AccountAccess.user_fk, user.userPrimaryKey.value), + By(AccountAccess.consumer_id, consumerId) + ).map(row => BankIdAccountIdViewId(BankId(row.bank_id.get), AccountId(row.account_id.get), ViewId(row.view_id.get))) + } + //returns Full if deletable, Failure if not def canRevokeOwnerAccessAsBox(bankId: BankId, accountId: AccountId, viewDefinition : ViewDefinition, user : User) : Box[Unit] = { if(canRevokeOwnerAccess(bankId: BankId, accountId: AccountId, viewDefinition, user)) Full(Unit) diff --git a/obp-api/src/main/scala/code/views/Views.scala b/obp-api/src/main/scala/code/views/Views.scala index c1210cd0b0..36a76b67d0 100644 --- a/obp-api/src/main/scala/code/views/Views.scala +++ b/obp-api/src/main/scala/code/views/Views.scala @@ -42,6 +42,10 @@ trait Views { def grantAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view : View, user : User, consumerId : String) : Box[View] def grantAccessToCustomViewForConsumer(bankIdAccountIdViewId : BankIdAccountIdViewId, user : User, consumerId : String) : Box[View] def revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId : BankIdAccountIdViewId, user : User, consumerId : String) : Box[Boolean] + // Everything a user currently holds under one application's scope. The consent flows reconcile + // against this: a consent's granted views are whatever its JWT says, so the rows that back it are + // brought to match rather than deleted and rewritten. + def accessGrantedToUserForConsumer(user : User, consumerId : String) : List[BankIdAccountIdViewId] def customView(viewId : ViewId, bankAccountId: BankIdAccountId) : Box[View] def systemView(viewId : ViewId) : Box[View] diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 8a65af1972..0084a1cd7f 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -296,7 +296,7 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { // claim that this OAuth1-signed test suite cannot mint (see the comment above // "GET /aisp/accounts" below). feature("UKOB v4.0.1 Consent.grantUKConsentAccountAccess binds permissions to the selected account only") { - scenario("consent scoped to ReadAccountsBasic grants that view but not ReadBalances", UKOpenBankingV401AccountInfo) { + scenario("the consent's scope lands in its JWT, and the PSU gains nothing", UKOpenBankingV401AccountInfo) { val userExtended = UserExtended(resourceUser1) val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) @@ -313,13 +313,26 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { 10.seconds) result.isDefined should equal(true) - // Granted: the account now has a real (non-null) AccountAccess row for the consented view. + // The JWT is the consent's scope: binding replaces the (null, null, permission) placeholders + // createUKConsentJWT wrote with the real account, for the declared permission and no other. + // What that scope is worth at request time is pinned in UKOpenBankingV401ConsentScopingTests, + // which drives the whole applyUKRules path. + val boundViews = { + implicit val formats = code.api.util.CustomJsonFormats.formats + val updated = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + code.api.util.JwtUtil.getSignedPayloadAsJson(updated.jsonWebToken) + .map(com.openbankproject.commons.util.JsonAliases.parse(_).extract[code.api.util.ConsentJWT]) + .openOrThrowException("consent jwt").views + } + boundViews.map(v => (v.bank_id, v.account_id, v.view_id)) should equal( + List((testBankId1.value, acc, Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID))) + + // And the PSU is left exactly as they were. Binding used to write AccountAccess rows under + // the PSU, which is what made every consent that PSU granted share one set of rows; the + // consent's access now belongs to a principal of its own. userExtended.hasAccountAccess( Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), - bankIdAccountId, None) should equal(true) - - // Not granted: ReadBalances was never in the consent's Permissions, so it must stay locked — - // this is the check GET /aisp/accounts/ACCOUNT_ID/balances relies on (checkViewAccessAndReturnView). + bankIdAccountId, None) should equal(false) userExtended.hasAccountAccess( Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_BALANCES_VIEW_ID).openOrThrowException("view"), bankIdAccountId, None) should equal(false) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala index 845076ff50..bf596665ef 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -3,11 +3,12 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.Constant import code.api.util.APIUtil.DateWithDayFormat import code.api.util.{CallContext, Consent} -import code.consent.Consents +import code.consent.{ConsentStatus, Consents} +import code.entitlement.Entitlement import code.model.UserExtended import code.views.Views import code.views.system.AccountAccess -import com.openbankproject.commons.model.{BankIdAccountId, ViewId} +import com.openbankproject.commons.model.{BankIdAccountId, User, ViewId} import net.liftweb.common.Full import org.scalatest.Tag @@ -15,28 +16,26 @@ import scala.concurrent.Await import scala.concurrent.duration._ /** - * What a UK consent's declared Permissions are actually worth, once more than one consent exists. + * What a UK consent's declared scope is actually worth, once more than one consent exists. * - * UK is the only standard that materialises consent permissions onto the *real* PSU: Berlin Group - * and OBP-native mint a fresh shadow user per consent (createXxxConsentJWT sets sub to a random - * UUID, and applyConsentRules resolves the principal with getOrCreateUser(consent.sub, ...)), so - * their AccountAccess rows are isolated by construction. grantUKConsentAccountAccess instead grants - * to the authorising PSU, so every UK consent for that PSU lands on the same (user, account, view) - * rows -- and those rows carry no consent identity at all. + * A UK consent's data access runs as the consent's own shadow user: the consent JWT carries a + * random UUID in `sub`, and applyUKRules resolves that to a user that exists only for this consent + * and grants it exactly the views the JWT names (see Consent.resolveUKConsentPrincipal). Berlin + * Group and OBP-native have always worked this way; UK used to grant to the real PSU instead, which + * meant every consent that PSU had granted wrote to one shared set of AccountAccess rows and + * silently rewrote each other's. * - * That makes three properties worth pinning down, none of which any existing suite covers: - * - narrowing: re-authorising with fewer permissions -- or fewer accounts -- must actually drop - * what was left out; - * - isolation: one TPP's authorisation must not rewrite another TPP's access to the same account; - * - and the one place narrowing still cannot hold: two consents live at once under the SAME TPP, - * which share a single set of rows. That is pinned as a characterisation test at the bottom of - * this file rather than left unstated -- it is the remaining half of the same gap, and it only - * closes when AccountAccess carries a consent_id. + * The properties worth pinning down, in the order they were lost historically: + * - narrowing: a consent that names fewer permissions, or fewer accounts, must have less; + * - independence: two consents live at once under the SAME TPP must not see each other's scope -- + * this is the one that was still open when access was keyed on the PSU; + * - isolation: one TPP's authorisation must not rewrite another TPP's access; + * - and the scope must be all the principal has: no account ownership, no roles, so none of the + * checks that run before the AccountAccess lookup (firehose, ABAC) can answer for it. * - * Asserted at the UserExtended.hasAccountAccess layer because that is exactly what - * APIUtil.checkViewAccessAndReturnView -- and therefore every UK data endpoint -- consults. The - * full HTTP path can't be driven here: these OAuth1-signed requests carry no Bearer JWT with a - * consent_id claim, so the data endpoints would stop at 403 ConsentIdClaimMissing. + * Asserted at the UserExtended.hasAccountAccess layer, driven through the real applyUKRules + * entry point, because that is exactly what APIUtil.checkViewAccessAndReturnView -- and therefore + * every UK data endpoint -- consults. */ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup { @@ -49,13 +48,14 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup private val ReadAccountsBasic = Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID private val ReadBalances = Constant.SYSTEM_READ_BALANCES_VIEW_ID + private val FirehoseRole = code.api.util.ApiRole.canUseAccountFirehoseAtAnyBank.toString private def systemView(viewId: String) = Views.views.vend.getOrCreateSystemView(viewId).openOrThrowException(s"could not create system view $viewId") /** - * Create a UK consent held by `consumerId` and run the authorise-time grant on it, i.e. the same - * call the POST /consents/CONSENT_ID/authorise endpoint makes once SCA has passed. + * Create a UK consent held by `consumerId`, bind it to accounts, and mark it AUTHORISED -- i.e. + * everything the POST /consents/CONSENT_ID/authorise endpoint does once SCA has passed. */ private def authoriseConsentFor(consumerId: String, permissions: List[String], @@ -73,23 +73,18 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup apiVersion = Some("4.0.1") ).openOrThrowException("test consent creation failed").consentId - // saveUKConsent hands back the ConsentTrait; grantUKConsentAccountAccess wants the MappedConsent. - val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) - .openOrThrowException(s"consent $consentId not found") - // The seven UK permission views are not seeded in the test DB (Boot only creates // owner/auditor/accountant/... unless additional_system_views is set, and test.default.props // does not set it), so they have to exist before the grant can bind them. permissions.foreach(systemView) - Await.result( - Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, accountIds, consent, None), - 10.seconds) + reAuthorise(consentId, accountIds) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) consentId } - /** Re-run the authorise-time grant on a consent that already exists and is still live -- the PSU - * re-authenticating against a consent the TPP never revoked. */ + /** Re-run the authorise-time binding on a consent that already exists and is still live -- the + * PSU re-authenticating against a consent the TPP never revoked. */ private def reAuthorise(consentId: String, accountIds: List[String]): Unit = { val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) .openOrThrowException(s"consent $consentId not found") @@ -98,102 +93,143 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup 10.seconds) } - /** Access as it is evaluated for a request arriving from `consumer` -- the consumer is what - * User.hasAccountAccess keys its consumer-specific lookup on before falling back to ALL_CONSUMERS. */ + /** + * Authenticate a request the way a caller presenting this consent would, and hand back the + * principal it resolves to plus the CallContext the endpoint would see. This is the real + * Consent-Id / Consent-JWT header path, gates and all. + */ + private def authenticateWith(consentId: String, consumer: code.model.Consumer): (User, CallContext) = { + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException(s"consent $consentId not found") + val (user, callContext) = Await.result( + Consent.applyUKRules(consent, "", CallContext(consumer = Full(consumer))), + 10.seconds) + (user.openOrThrowException(s"consent $consentId did not authenticate: $user"), + callContext.getOrElse(CallContext(consumer = Full(consumer)))) + } + + /** Access as it is evaluated for a request arriving with `consentId` from `consumer`. */ private def canRead(viewId: String, + consentId: String, consumer: code.model.Consumer, - account: BankIdAccountId = bankIdAccountId): Boolean = - UserExtended(resourceUser1).hasAccountAccess( - systemView(viewId), - account, - Some(CallContext(consumer = Full(consumer)))) + account: BankIdAccountId = bankIdAccountId): Boolean = { + val (principal, callContext) = authenticateWith(consentId, consumer) + UserExtended(principal).hasAccountAccess(systemView(viewId), account, Some(callContext)) + } feature("A UK consent is authoritative for the permissions it declares") { - scenario("re-authorising with fewer permissions drops the ones left out", UKConsentScoping) { - authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) - canRead(ReadAccountsBasic, testConsumer) should equal(true) - canRead(ReadBalances, testConsumer) should equal(true) - - // Same TPP, narrower consent: ReadBalances was not asked for this time, so it must go. - authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) - canRead(ReadAccountsBasic, testConsumer) should equal(true) - canRead(ReadBalances, testConsumer) should equal(false) + scenario("a consent that did not ask for a permission does not have it", UKConsentScoping) { + val wide = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) + canRead(ReadAccountsBasic, wide, testConsumer) should equal(true) + canRead(ReadBalances, wide, testConsumer) should equal(true) + + // Same TPP, same PSU, same account -- but this consent never asked for balances. + val narrow = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + canRead(ReadAccountsBasic, narrow, testConsumer) should equal(true) + canRead(ReadBalances, narrow, testConsumer) should equal(false) } + } feature("A UK consent is authoritative for the accounts it names") { - scenario("re-authorising with fewer accounts drops the accounts left out", UKConsentScoping) { - authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), + scenario("a consent does not reach an account it never named", UKConsentScoping) { + val both = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), accountIds = List(acc, otherAcc)) - canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(true) - canRead(ReadBalances, testConsumer, otherBankIdAccountId) should equal(true) + canRead(ReadAccountsBasic, both, testConsumer, otherBankIdAccountId) should equal(true) - // Same TPP, same permissions, but the PSU dropped otherAcc from the selection this time. - // The account the consent no longer names must lose every UK permission view with it -- - // otherwise the consent reads accounts it never declared. - authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), + val onlyOne = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), accountIds = List(acc)) - canRead(ReadAccountsBasic, testConsumer) should equal(true) - canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(false) - canRead(ReadBalances, testConsumer, otherBankIdAccountId) should equal(false) + canRead(ReadAccountsBasic, onlyOne, testConsumer) should equal(true) + canRead(ReadAccountsBasic, onlyOne, testConsumer, otherBankIdAccountId) should equal(false) + canRead(ReadBalances, onlyOne, testConsumer, otherBankIdAccountId) should equal(false) + } + + scenario("re-authorising one consent with fewer accounts narrows it", UKConsentScoping) { + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc, otherAcc)) + canRead(ReadAccountsBasic, consentId, testConsumer, otherBankIdAccountId) should equal(true) + + // The PSU re-authorises and drops otherAcc from the selection. Nothing sweeps anything: the + // JWT no longer names that account, so the next request simply does not re-grant it. + reAuthorise(consentId, List(acc)) + canRead(ReadAccountsBasic, consentId, testConsumer) should equal(true) + canRead(ReadAccountsBasic, consentId, testConsumer, otherBankIdAccountId) should equal(false) } } - /** - * Characterisation test: this pins behaviour that is WRONG but currently accepted, so that the - * limitation is visible in the suite rather than only in a status file. When AccountAccess grows - * a consent_id column the assertions below become false and this scenario fails -- that failure - * is the signal to flip them to the values the comments name, not to relax them. - */ - feature("KNOWN LIMITATION: two live consents held by the same TPP share one set of rows") { - scenario("re-authorising the wider consent widens a narrower live one", UKConsentScoping) { + feature("Two live consents held by the same TPP are scoped independently") { + scenario("re-authorising the wider consent does not widen the narrower one", UKConsentScoping) { val wide = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), accountIds = List(acc, otherAcc)) val narrow = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), accountIds = List(acc)) - // The fix this suite pins: authorising `narrow` drops the account it does not name. - canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(false) + canRead(ReadAccountsBasic, narrow, testConsumer, otherBankIdAccountId) should equal(false) - // But `wide` was never revoked, and the PSU re-authenticating against it re-grants otherAcc. - // Both consents are live and both resolve to the same (user, account, view, consumer) rows, - // so there is no answer that is right for both: whichever was authorised last wins. + // Both consents are live and both belong to the same TPP and the same PSU. While access was + // keyed on the PSU they shared one set of rows, so re-authorising the wider one handed the + // narrower one an account it never named. Each now has a principal of its own. reAuthorise(wide, List(acc, otherAcc)) - // Correct for `wide`. WRONG for `narrow`, which still names only `acc` -- with a consent_id - // column this stays false when the request presents `narrow`. Asserted true because that is - // what the code does today, and pretending otherwise would hide the hole. - canRead(ReadAccountsBasic, testConsumer, otherBankIdAccountId) should equal(true) + canRead(ReadAccountsBasic, wide, testConsumer, otherBankIdAccountId) should equal(true) + canRead(ReadAccountsBasic, narrow, testConsumer, otherBankIdAccountId) should equal(false) } } feature("One TPP's UK consent does not rewrite another TPP's access") { scenario("a second consumer authorising a narrower consent leaves the first consumer's access intact", UKConsentScoping) { - authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) - authoriseConsentFor(testConsumer2.consumerId.get, List(ReadAccountsBasic)) + val first = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) + val second = authoriseConsentFor(testConsumer2.consumerId.get, List(ReadAccountsBasic)) - // The second TPP never asked for balances, so it must not have them. - canRead(ReadBalances, testConsumer2) should equal(false) - - // ...and the first TPP's consent is none of the second TPP's business: authorising a consent - // must not narrow access that was granted to somebody else. - canRead(ReadBalances, testConsumer) should equal(true) + canRead(ReadBalances, second, testConsumer2) should equal(false) + canRead(ReadBalances, first, testConsumer) should equal(true) } } - feature("A UK consent grant leaves account-ownership access alone") { - scenario("the owner view survives, still granted to every consumer", UKConsentScoping) { + feature("A UK consent's principal has the consent's scope and nothing else") { + scenario("it holds no account ownership and no roles, so no check above the view lookup can answer for it", UKConsentScoping) { + // Give the PSU the role that lets account firehose bypass the AccountAccess check entirely. + // APIUtil.hasAccountAccess consults firehose (and then ABAC) BEFORE the view lookup, so if the + // consent ran as the PSU this role would make its declared scope meaningless. + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, FirehoseRole) + + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + val (principal, _) = authenticateWith(consentId, testConsumer) + + principal.userId should not equal resourceUser1.userId + Entitlement.entitlement.vend.getEntitlementsByUserId(principal.userId) + .getOrElse(Nil) shouldBe empty + + // ...and no ownership of any account, so the owner view is not reachable either. + AccountAccess.findByUniqueIndex( + testBankId1, testAccountId1, ViewId(Constant.SYSTEM_OWNER_VIEW_ID), + principal.userPrimaryKey, Constant.ALL_CONSUMERS + ).isDefined should equal(false) + } + + scenario("account ownership is left alone: the PSU keeps the owner view", UKConsentScoping) { authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) - // owner comes from holding the account, not from any consent, so it stays ALL_CONSUMERS and - // must never be caught by a consent's revoke pass. + // owner comes from holding the account, not from any consent. Nothing in the consent flow + // writes or removes a row for the PSU any more, so it is untouched. AccountAccess.findByUniqueIndex( - testBankId1, - testAccountId1, - ViewId(Constant.SYSTEM_OWNER_VIEW_ID), - resourceUser1.userPrimaryKey, - Constant.ALL_CONSUMERS + testBankId1, testAccountId1, ViewId(Constant.SYSTEM_OWNER_VIEW_ID), + resourceUser1.userPrimaryKey, Constant.ALL_CONSUMERS ).isDefined should equal(true) } } + + feature("Revoking a UK consent takes its access away") { + scenario("the granted rows are gone, not merely unreachable", UKConsentScoping) { + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + val (principal, _) = authenticateWith(consentId, testConsumer) + Views.views.vend.accessGrantedToUserForConsumer(principal, Constant.ALL_CONSUMERS) should not be empty + + Consents.consentProvider.vend.revoke(consentId) + + // Revocation used to flip a status column and leave the granted rows in the table for good. + Views.views.vend.accessGrantedToUserForConsumer(principal, Constant.ALL_CONSUMERS) shouldBe empty + } + } + } From 32739c7cd8a479ad9f67be5304c2c18359cf7f7f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 13:46:03 +0200 Subject: [PATCH 39/63] perf: skip the lookup for consent views that are already granted grantAccessToViews runs on every request that presents a consent. The reconcile already meant a steady-state request wrote nothing, but it still resolved every consented view to decide it had nothing to do -- two DB lookups per view per request on a consent that had been used before, which is the common case. Filter the already-granted views out before the loop instead, so that request does no lookups either. --- .../src/main/scala/code/api/util/ConsentUtil.scala | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 ea796f1f93..b506847f84 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -424,13 +424,15 @@ object Consent extends MdcLoggable { val heldSet = held.toSet val result: List[Box[View]] = { for { - view <- consent.views + // A view that is already granted and still wanted needs nothing done to it, and nothing + // looked up either: on a consent that has been used before -- the common case, since this + // runs on every request -- this loop does no work at all. + view <- consent.views.filterNot { view => + heldSet.contains(BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id), ViewId(view.view_id))) + } } yield { val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) - if (heldSet.contains(bankIdAccountIdViewId)) { - // Already granted and still wanted -- leave the row alone. - Views.views.vend.systemView(ViewId(view.view_id)).or(Views.views.vend.customView(ViewId(view.view_id), BankIdAccountId(BankId(view.bank_id), AccountId(view.account_id)))) - } else Views.views.vend.systemView(ViewId(view.view_id)) match { + Views.views.vend.systemView(ViewId(view.view_id)) match { case Full(systemView) => if (isConsumerScoped) Views.views.vend.grantAccessToSystemViewForConsumer(BankId(view.bank_id), AccountId(view.account_id), systemView, user, consumerId) From 5a02393ddabc4a1ab57eb83ce77d80a8d7e090b9 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 13:48:50 +0200 Subject: [PATCH 40/63] test: cover the access-token path a UK consent normally arrives on The scoping suite drives applyUKRules, which is the Consent-Id header path. UK Open Banking specifies the other one: the consent travels as a consent_id claim inside the OAuth2 access token, and the principal is swapped at the end of authentication rather than during it. That path had no coverage at all. Pin it with the same self-signed-JWT harness the expiry test already uses: the principal becomes the consent's, the PSU survives on the CallContext (checkUKConsent's ownership check and the CBS adapter both read it), the scope is the consent's and nothing more, and a token carrying no consent claim comes back untouched. --- ...UKOpenBankingV401ConsentScopingTests.scala | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala index bf596665ef..38ec27640d 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -219,6 +219,55 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup } } + /** + * The consent normally arrives as a `consent_id` claim inside the OAuth2 access token, not in a + * Consent-Id header. On that path the request is authenticated as the PSU before any UK code + * runs, so the principal is swapped at the end of authentication instead + * (Consent.applyUKConsentPrincipalFromToken). Everything above drives the header path; this + * drives the token one, since it is the path the standard actually specifies. + * + * The token is self-signed: JwtUtil.getOptionalClaim parses structurally and does not verify, and + * the real access token would have been verified by OAuth2Login long before this point. + */ + private def bearerContextFor(consentId: String, consumer: code.model.Consumer): CallContext = { + val claims = new com.nimbusds.jwt.JWTClaimsSet.Builder().claim("consent_id", consentId).build() + CallContext( + user = Full(resourceUser1), + consumer = Full(consumer), + authReqHeaderField = Full(s"Bearer ${code.api.util.CertificateUtil.jwtWithHmacProtection(claims)}")) + } + + feature("A UK consent presented in an access token resolves the same way as one in a header") { + scenario("the principal is swapped, the PSU is kept, and the scope is the consent's", UKConsentScoping) { + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc)) + + val (principal, callContext) = + Consent.applyUKConsentPrincipalFromToken(Full(resourceUser1), Some(bearerContextFor(consentId, testConsumer))) + val resolved = principal.openOrThrowException("token path did not resolve a principal") + val cc = callContext.getOrElse(fail("token path dropped the CallContext")) + + resolved.userId should not equal resourceUser1.userId + // The PSU has to survive the swap: checkUKConsent compares the consent's owner against it, + // and the CBS adapter and metrics both name it. + cc.consenter.map(_.userId) should equal(Full(resourceUser1.userId)) + + UserExtended(resolved).hasAccountAccess(systemView(ReadAccountsBasic), bankIdAccountId, Some(cc)) should equal(true) + UserExtended(resolved).hasAccountAccess(systemView(ReadBalances), bankIdAccountId, Some(cc)) should equal(false) + UserExtended(resolved).hasAccountAccess(systemView(ReadAccountsBasic), otherBankIdAccountId, Some(cc)) should equal(false) + + // And the consent's ownership check still passes, because it is asked about the PSU. + Consent.checkUKConsent(resolved, Some(cc)).isDefined should equal(true) + } + + scenario("a token with no consent claim is left exactly as it is", UKConsentScoping) { + val plain = CallContext(user = Full(resourceUser1), consumer = Full(testConsumer)) + val (principal, callContext) = Consent.applyUKConsentPrincipalFromToken(Full(resourceUser1), Some(plain)) + principal.map(_.userId) should equal(Full(resourceUser1.userId)) + callContext.flatMap(_.consenter.toOption) should equal(None) + } + } + feature("Revoking a UK consent takes its access away") { scenario("the granted rows are gone, not merely unreachable", UKConsentScoping) { val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) From 0e43179f3458b47fc8613e60c3c0c29690719e62 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 13:53:21 +0200 Subject: [PATCH 41/63] fix: filter consent principals out of the user list in SQL, not after paging Hiding them with a filter over the query result meant the limit/offset had already been applied, so a page containing consent principals came back short -- the same defect the ?locked= path in this method has. Push it into the WHERE clause instead. Both shapes the column takes are covered: NULL for rows written before it existed, empty for anyone not minted by a consent. --- .../src/main/scala/code/users/LiftUsers.scala | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index ee2bc891cc..4e74a0fbf5 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -177,16 +177,22 @@ object LiftUsers extends Users with MdcLoggable{ Some(By(ResourceUser.IsDeleted, false)) // There is no query parameter "is_deleted" ) - val optionalParams: Seq[QueryParam[ResourceUser]] = Seq(limit.toSeq, offset.toSeq, deleted.toSeq).flatten - // Users a consent minted for itself are not people and do not belong in a list of people: they // have no username and no email, there is one of them for every consent ever granted, and they // outnumber real users by orders of magnitude on any busy instance. They stay reachable by id // and through the account-access data; they just do not pad out this list. - def isConsentPrincipal(user: ResourceUser): Boolean = - Option(user.CreatedByConsentId.get).exists(_.trim.nonEmpty) - - def getAllResourceUsers(): List[ResourceUser] = ResourceUser.findAll(optionalParams: _*).filterNot(isConsentPrincipal) + // + // Filtered in SQL rather than after the fact, so it composes with the limit/offset above: a + // filter applied to an already-paginated result returns short pages, which is exactly the + // defect the ?locked= path below has. + val notMintedByAConsent = BySql[ResourceUser]( + "(createdbyconsentid IS NULL OR createdbyconsentid = '')", + IHaveValidatedThisSQL("hongwei", "2026-08-01")) + + val optionalParams: Seq[QueryParam[ResourceUser]] = + Seq(limit.toSeq, offset.toSeq, deleted.toSeq, Seq(notMintedByAConsent)).flatten + + def getAllResourceUsers(): List[ResourceUser] = ResourceUser.findAll(optionalParams: _*) val showUsers: List[ResourceUser] = locked.map(_.toLowerCase()) match { case Some("active") => From b51f195cc67d32ba5285e6c0dec0ea944b37781b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 1 Aug 2026 14:17:34 +0200 Subject: [PATCH 42/63] fix: recover from the duplicate-key race when minting a consent principal The shadow user is created lazily, on the first request that presents the consent -- so the first two requests race: both look, both find nothing, both insert, and one loses on the unique index over (provider, providerId). LiftUsers.getOrCreateUserByProviderId already handles exactly this by re-reading after a failed insert. It just was not on the Users trait, because only the Future form had been needed. Put it there and use it, rather than repeating the find-then-insert by hand. --- .../scala/code/api/util/ConsentUtil.scala | 31 ++++++++----------- obp-api/src/main/scala/code/users/Users.scala | 5 +++ 2 files changed, 18 insertions(+), 18 deletions(-) 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 b506847f84..a634c36b7e 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -977,24 +977,19 @@ object Consent extends MdcLoggable { * identify the customer, so a shadow user without them would be a different caller to the CBS. */ private def getOrCreateUKConsentShadowUser(storedConsent: MappedConsent, consentJwt: ConsentJWT): Box[User] = { - Users.users.vend.getUserByProviderId(provider = consentJwt.iss, idGivenByProvider = consentJwt.sub) match { - case Full(existing) => Full(existing) - case _ => - for { - created <- Users.users.vend.createResourceUser( - provider = consentJwt.iss, - providerId = Some(consentJwt.sub), - createdByConsentId = Some(storedConsent.consentId), - name = None, - email = None, - userId = None, - createdByUserInvitationId = None, - company = None, - lastMarketingAgreementSignedDate = None - ) ?~! ErrorMessages.CannotGetOrCreateUser - _ = copyAuthContextOfConsentToUser(storedConsent.consentId, created.userId, newUser = true) - } yield created - } + // Reuses the shared get-or-create rather than doing its own find-then-insert: this runs on + // every request, so the very first two requests for a new consent race, and only the shared one + // recovers from the resulting unique-index violation by re-reading. + val (user, isNew) = Users.users.vend.getOrCreateUserByProviderId( + provider = consentJwt.iss, + idGivenByProvider = consentJwt.sub, + consentId = Some(storedConsent.consentId), + name = None, + email = None) + for { + shadowUser <- user ?~! ErrorMessages.CannotGetOrCreateUser + _ = if (isNew) copyAuthContextOfConsentToUser(storedConsent.consentId, shadowUser.userId, newUser = true) + } yield shadowUser } def applyRulesOldStyle(consentId: Option[String], callContext: CallContext): (Box[User], CallContext) = { diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index 56bc3b4b00..27a300a5fc 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -31,6 +31,11 @@ trait Users { def getUserByProviderId(provider : String, idGivenByProvider : String) : Box[User] def getUserByProviderIdFuture(provider : String, idGivenByProvider : String) : Future[Box[User]] def getOrCreateUserByProviderIdFuture(provider : String, idGivenByProvider : String, consentId: Option[String], name: Option[String], email: Option[String]) : Future[(Box[User], Boolean)] + // The synchronous form of the above, for callers already inside a Box for-comprehension. Carries + // the same duplicate-key recovery: two concurrent first requests both find nothing and both + // insert, so the loser re-reads instead of failing. Second element is true when the user was + // created by this call. + def getOrCreateUserByProviderId(provider : String, idGivenByProvider : String, consentId: Option[String], name: Option[String], email: Option[String]) : (Box[User], Boolean) //resourceuser has two ids: id(Long)and userid_(String), this method use userid_(String) def getUserByUserId(userId : String) : Box[User] From a130554073fc07cabeb520bb689584f92b0938c6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 3 Aug 2026 15:18:48 +0200 Subject: [PATCH 43/63] fix: stamp the build with the working copy that produced it (#55) * fix: stamp the build with the working copy that produced it git.properties came from git-commit-id-maven-plugin, and in a git worktree it described the wrong repository. The plugin bundles JGit 6.7, which has no commondir support and so cannot read a linked worktree's gitdir; its GitDirLocator.resolveWorktree() works around that by redirecting
/.git/worktrees/ back to
/.git. Every build run from a worktree therefore stamped the main checkout's branch and commit, and /status named a revision that never produced the running jar -- the failure commit 6727bd372 set out to make visible. Its PropertiesFileGenerator then skipped rewriting whenever only git.build.time differed, so the timestamp froze at the first build too. Generate the stamp with scripts/write_git_properties.sh instead, invoked from obp-api's maven-antrun execution at generate-resources. The git CLI is worktree-aware by construction and the script rewrites unconditionally. It writes straight into target/classes rather than the source tree, and only for obp-api: the plugin was declared in the parent pom as well, which put a second git.properties on the runtime classpath where whichever one /status read was incidental. Missing git or no repository yields git.commit.id=unknown rather than a failed build, matching the old failOnNoGitDirectory=false. Report git_branch and git_build_time on /status next to the commit, since a wrong branch was the symptom and neither was previously visible. test_worktree_build.yml only asserted the fields were non-empty, which the wrong values satisfied. It now compares them against the worktree's own HEAD and branch, rebuilds without clean at a new commit to prove the stamp moves, and checks the jar ships it. * test: pin /status to the build stamp the artifact carries Nothing covered the status page, which is the thing an operator reads to tell which build is running -- so the branch it reported being the main checkout's rather than the worktree's went unnoticed. Assert git_commit matches what APIUtil reads from the same classpath stamp, and that the branch, dirty flag and build time are all present. * ci: run the worktree build check on JDK 25 It requested JDK 11 while the build compiles with -release 25, so the job could never get past compilation -- which is why the stamp it is meant to guard went wrong unnoticed. --- .github/workflows/test_worktree_build.yml | 103 ++++++++++++--- CLAUDE.md | 2 + obp-api/pom.xml | 45 ++++--- .../code/api/util/http4s/StatusPage.scala | 25 +++- .../Http4sServerIntegrationTest.scala | 23 ++++ pom.xml | 25 +--- scripts/write_git_properties.sh | 124 ++++++++++++++++++ 7 files changed, 291 insertions(+), 56 deletions(-) create mode 100755 scripts/write_git_properties.sh diff --git a/.github/workflows/test_worktree_build.yml b/.github/workflows/test_worktree_build.yml index 86bf256e14..936d1895c6 100644 --- a/.github/workflows/test_worktree_build.yml +++ b/.github/workflows/test_worktree_build.yml @@ -18,15 +18,19 @@ jobs: ref: ${{ github.event.inputs.branch }} fetch-depth: 0 - - name: Set up JDK 11 + # Same JDK as build_pull_request.yml — the build compiles with -release 25, + # so the JDK 11 this job used to request could never get past compilation. + - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: "11" - distribution: "adopt" + java-version: "25" + distribution: "temurin" cache: maven + # On a branch, not detached: the point of this job is that the stamp names + # THIS working copy, and a detached worktree has no branch to compare against. - name: Create git worktree - run: git worktree add --detach ../obp-api-worktree HEAD + run: git worktree add -b worktree-stamp-probe ../obp-api-worktree HEAD - name: Build from worktree working-directory: ../obp-api-worktree @@ -34,7 +38,11 @@ jobs: set -o pipefail MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn clean package -DskipTests 2>&1 | tee "$GITHUB_WORKSPACE/worktree-build.log" - - name: Verify git.properties was generated + # A worktree build used to inherit the MAIN checkout's branch and commit + # (git-commit-id-maven-plugin redirected the worktree gitdir to the main .git), + # so "the fields are non-empty" passed while the values were wrong. Compare + # them against the worktree's own HEAD instead. + - name: Verify the stamp describes the worktree run: | PROPS_FILE="../obp-api-worktree/obp-api/target/classes/git.properties" if [ ! -f "$PROPS_FILE" ]; then @@ -44,20 +52,85 @@ jobs: echo "Contents of git.properties:" cat "$PROPS_FILE" - check_field() { - local field=$1 - local value - value=$(grep "^${field}=" "$PROPS_FILE" | cut -d'=' -f2-) - if [ -z "$value" ]; then - echo "FAIL: $field is empty or missing" + read_field() { + grep "^${1}=" "$PROPS_FILE" | cut -d'=' -f2- | sed 's/\\//g' + } + + expect_field() { + local field=$1 expected=$2 actual + actual=$(read_field "$field") + if [ "$actual" != "$expected" ]; then + echo "FAIL: $field is '$actual', expected '$expected'" exit 1 fi - echo "OK: $field=$value" + echo "OK: $field=$actual" + } + + expect_field "git.commit.id" "$(git -C ../obp-api-worktree rev-parse HEAD)" + expect_field "git.branch" "worktree-stamp-probe" + + BUILD_TIME=$(read_field "git.build.time") + if [ -z "$BUILD_TIME" ]; then + echo "FAIL: git.build.time is empty" + exit 1 + fi + echo "OK: git.build.time=$BUILD_TIME" + echo "FIRST_COMMIT=$(read_field git.commit.id)" >> "$GITHUB_ENV" + echo "FIRST_BUILD_TIME=$BUILD_TIME" >> "$GITHUB_ENV" + + # The other half of the bug: the stamp was written once and then reused, so an + # incremental rebuild at a new commit shipped the previous commit's values. + - name: Rebuild at a new commit without clean and verify the stamp moved + working-directory: ../obp-api-worktree + run: | + set -o pipefail + sleep 1 + git -c user.name=ci -c user.email=ci@example.com commit --allow-empty -m "stamp probe" + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn package -DskipTests 2>&1 | tee -a "$GITHUB_WORKSPACE/worktree-build.log" + + PROPS_FILE="obp-api/target/classes/git.properties" + read_field() { + grep "^${1}=" "$PROPS_FILE" | cut -d'=' -f2- | sed 's/\\//g' } - check_field "git.commit.id" - check_field "git.branch" - check_field "git.build.time" + NEW_COMMIT=$(read_field git.commit.id) + NEW_BUILD_TIME=$(read_field git.build.time) + + if [ "$NEW_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "FAIL: after the rebuild git.commit.id is '$NEW_COMMIT', expected '$(git rev-parse HEAD)'" + exit 1 + fi + if [ "$NEW_COMMIT" = "$FIRST_COMMIT" ]; then + echo "FAIL: git.commit.id did not change across commits (stale stamp)" + exit 1 + fi + if [ "$NEW_BUILD_TIME" = "$FIRST_BUILD_TIME" ]; then + echo "FAIL: git.build.time did not change across builds (stale stamp)" + exit 1 + fi + echo "OK: stamp tracks the rebuild — $NEW_COMMIT at $NEW_BUILD_TIME" + + # The stamp belongs to obp-api alone; a second copy elsewhere on the + # classpath makes which one /status reports a coin flip. + FOUND=$(find . -name git.properties -not -path '*/target/lib/*' | sort) + if [ "$FOUND" != "./obp-api/target/classes/git.properties" ]; then + echo "FAIL: expected exactly one git.properties, found:" + echo "$FOUND" + exit 1 + fi + echo "OK: single git.properties on the classpath" + + - name: Verify the jar ships the stamp + working-directory: ../obp-api-worktree + run: | + unzip -p obp-api/target/obp-api.jar git.properties > /tmp/jar-git.properties + cat /tmp/jar-git.properties + JAR_COMMIT=$(grep '^git.commit.id=' /tmp/jar-git.properties | cut -d'=' -f2- | sed 's/\\//g') + if [ "$JAR_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "FAIL: jar stamp is '$JAR_COMMIT', expected '$(git rev-parse HEAD)'" + exit 1 + fi + echo "OK: obp-api.jar carries the current commit" - name: Upload build log if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 8e3da6a443..aa74ad264c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -265,6 +265,8 @@ Symptoms in tests: a v4-specific assertion fails (e.g. an entitlement should-be- **`isStatisticallyTooPermissive` is sample-pool-dependent**: a fresh local test DB with a single user trips the ABAC-permissiveness check and causes spurious rejections. Seed enough users in any test exercising ABAC rules — it's a test-data issue, not a regression. +**The build stamp comes from a script, not a Maven plugin**: `git.properties` (what `/status` and the root endpoint's `git_commit` report) is written by `scripts/write_git_properties.sh`, invoked from `obp-api/pom.xml`'s `maven-antrun-plugin` execution `generate-git-properties` at `generate-resources`, straight into `target/classes`. It used to be `git-commit-id-maven-plugin`, which was wrong in two ways: its bundled JGit 6.7 has no `commondir` support, so `GitDirLocator.resolveWorktree()` redirects a linked worktree's gitdir to the *main* checkout's `.git` — every build run from `.claude/worktrees/*` stamped the main checkout's branch and commit — and its `PropertiesFileGenerator` skips rewriting when only `git.build.time` differs, freezing the timestamp. Add stamp fields by editing the script (keep the `git.*` key names; `StatusPage.scala` and `APIUtil.gitCommit` read them by name), and don't reintroduce a per-module generator: exactly one `git.properties` may be on the runtime classpath, otherwise which one is reported is incidental. `.github/workflows/test_worktree_build.yml` guards both failure modes. + ## CI (shard map + run tips) Perf note: integration tests are DB/HTTP-bound (~0.4 s/test) on both frameworks; the http4s win is the **pure-unit tier** (no running server, ~0.008 s/test). `ResourceDocsTest`/`SwaggerDocsTest` are the slowest per-test cost — they serialize the whole API surface, so cost grows with endpoint count. `Http4sResourceDocs` already caches the serialized output (`Caching.{getDynamic,getStatic,getAll}ResourceDocCache` + `getStaticSwaggerDocCache`, keyed via `APIUtil.createResourceDocCacheKey`), so repeat requests for the same version/params skip re-serialization. diff --git a/obp-api/pom.xml b/obp-api/pom.xml index df5266a20a..2ee120e9f1 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -620,6 +620,33 @@ maven-antrun-plugin 3.1.0 + + + generate-git-properties + generate-resources + + run + + + + + + + + + + delete-surefire-xml-after-html verify @@ -704,24 +731,6 @@ - - io.github.git-commit-id - git-commit-id-maven-plugin - 9.0.1 - - - - revision - - - - - ${project.basedir}/.git - true - src/main/resources/git.properties - false - - org.apache.maven.plugins maven-compiler-plugin diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index e70b95dc06..2a1a21bbf4 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -24,12 +24,25 @@ object StatusPage extends MdcLoggable { props } - private def gitCommit: String = - Option(gitProps.getProperty("git.commit.id")).getOrElse("unknown") + private def gitProp(key: String): String = + Option(gitProps.getProperty(key)).map(_.trim).filter(_.nonEmpty).getOrElse("unknown") + + private def gitCommit: String = gitProp("git.commit.id") + + /** + * The branch and the moment the running artifact was built. Both come from the same stamp as the + * commit, and both were unusable until the stamp stopped being generated by + * git-commit-id-maven-plugin: in a git worktree it named the main checkout's branch, and it + * refused to rewrite the file when only the build time had changed. Absent from older stamps, so + * they degrade to "unknown" rather than failing. + */ + private def gitBranch: String = gitProp("git.branch") + + private def gitBuildTime: String = gitProp("git.build.time") /** - * True when the build had uncommitted changes. Reported alongside the commit because the plugin - * stamps the last commit, not what was compiled: a build from a modified working tree names a + * True when the build had uncommitted changes. Reported alongside the commit because the stamp + * names the last commit, not what was compiled: a build from a modified working tree names a * revision whose source never produced this artifact, and checking that id out would not * reproduce it. Absent from older stamps, so it defaults to false rather than failing. */ @@ -168,7 +181,9 @@ object StatusPage extends MdcLoggable { | "status": "$status", | "api_instance_id": "$apiInstanceId", | "git_commit": "$gitCommit", + | "git_branch": "${jsonEscape(gitBranch)}", | "git_dirty": $gitDirty, + | "git_build_time": "${jsonEscape(gitBuildTime)}", | "uptime_seconds": $uptimeSeconds, | "checks": { | "database": "${checks.database}", @@ -229,6 +244,8 @@ object StatusPage extends MdcLoggable { "commit, not necessarily what is running" else "" } + | git_branch${htmlEscape(gitBranch)} + | git_build_time${htmlEscape(gitBuildTime)} | uptime_seconds$uptimeSeconds | | diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala index 9845c9be3b..7ed98ce8a3 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala @@ -83,6 +83,29 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser http4sServer.port should equal(APIUtil.getPropsAsIntValue("http4s.test.port", 8087)) } + // The point of /status is telling an operator which build is running. It reported the + // wrong branch and a frozen build time for any build made from a git worktree, so pin + // what it reports to the stamp the artifact actually carries (scripts/write_git_properties.sh + // writes it; APIUtil.gitCommit reads the same file from the classpath). + scenario("GET /status reports the build stamp this artifact carries", Http4sServerIntegrationTag) { + Given("HTTP4S test server is running") + + When("We request the status page as JSON") + val (status, body) = makeHttp4sGetRequest("/status", Map("Accept" -> "application/json")) + + Then("We should get a status response") + status should (equal(200) or equal(503)) + + And("It should carry the commit the running classes were built from") + val json = parse(body) + (json \ "git_commit").extract[String] should equal(APIUtil.gitCommit) + + And("It should also name the branch, the dirty flag and the build time") + (json \ "git_branch").extract[String] should not be empty + (json \ "git_build_time").extract[String] should not be empty + List(JBool(true), JBool(false)) should contain(json \ "git_dirty") + } + scenario("Server handles 404 for unknown routes", Http4sServerIntegrationTag) { Given("HTTP4S test server is running") diff --git a/pom.xml b/pom.xml index c460355604..90f956d09b 100644 --- a/pom.xml +++ b/pom.xml @@ -288,25 +288,12 @@ - - io.github.git-commit-id - git-commit-id-maven-plugin - 9.0.1 - - - - revision - - - - - ${project.basedir}/.git - true - src/main/resources/git.properties - false - - - + org.apache.maven.plugins diff --git a/scripts/write_git_properties.sh b/scripts/write_git_properties.sh new file mode 100755 index 0000000000..12f806e8e3 --- /dev/null +++ b/scripts/write_git_properties.sh @@ -0,0 +1,124 @@ +#!/bin/bash +################################################################################ +# Write the build stamp (git.properties) that /status reports. +# +# Usage: write_git_properties.sh +# +# Why a script and not git-commit-id-maven-plugin: that plugin bundles JGit 6.7, +# which has no `commondir` support, so it cannot read a linked worktree's gitdir. +# Its GitDirLocator.resolveWorktree() works around that by collapsing +#
/.git/worktrees/ back to
/.git — meaning every build run from +# a worktree stamped the MAIN checkout's branch and commit. On top of that its +# PropertiesFileGenerator skips rewriting whenever the only changed key is +# git.build.time, so the stamp froze at the first build. The git CLI is +# worktree-aware by construction and we rewrite unconditionally, so both go away. +# +# Never fails the build: with no git binary or no repository we still emit a +# well-formed stamp with git.commit.id=unknown (the plugin behaved the same way +# via failOnNoGitDirectory=false, and source-tarball builds rely on it). +################################################################################ + +set -u + +OUT="${1:-}" +if [[ -z "$OUT" ]]; then + echo "write_git_properties.sh: output file argument is required" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Resolve the repository from the script's own location, not the caller's cwd: +# Maven invokes this from a module directory, and `git` must run against the +# working copy that owns this script. +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" + +g() { + # Echo a git command's first line, empty string on any failure. + git -C "$REPO_ROOT" "$@" 2>/dev/null | head -1 || true +} + +if [[ -n "$REPO_ROOT" ]]; then + BRANCH="$(g rev-parse --abbrev-ref HEAD)" + COMMIT_ID="$(g rev-parse HEAD)" + COMMIT_ABBREV="$(g rev-parse --short=7 HEAD)" + COMMIT_TIME="$(g log -1 --format=%cI)" + COMMIT_USER_NAME="$(g log -1 --format=%an)" + COMMIT_USER_EMAIL="$(g log -1 --format=%ae)" + COMMIT_MESSAGE_SHORT="$(g log -1 --format=%s)" + DESCRIBE="$(g describe --always --dirty --abbrev=7)" + TAGS="$(git -C "$REPO_ROOT" tag --points-at HEAD 2>/dev/null | paste -sd, - || true)" + REMOTE_URL="$(g config --get remote.origin.url)" + BUILD_USER_NAME="$(g config --get user.name)" + BUILD_USER_EMAIL="$(g config --get user.email)" + # Tracked modifications only, matching what git-commit-id reported: untracked + # scratch files (build logs, editor state) are always present in a working + # checkout and would pin the flag to true, which is exactly the "renders the + # same either way" problem 6727bd372 set out to fix. + if [[ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ]]; then + DIRTY="true" + else + DIRTY="false" + fi +else + BRANCH="" + COMMIT_ID="unknown" + COMMIT_ABBREV="unknown" + COMMIT_TIME="" + COMMIT_USER_NAME="" + COMMIT_USER_EMAIL="" + COMMIT_MESSAGE_SHORT="" + DESCRIBE="" + TAGS="" + REMOTE_URL="" + BUILD_USER_NAME="" + BUILD_USER_EMAIL="" + DIRTY="false" +fi + +[[ -z "$COMMIT_ID" ]] && COMMIT_ID="unknown" +[[ -z "$COMMIT_ABBREV" ]] && COMMIT_ABBREV="unknown" + +# BSD date (macOS) has no %:z, so put the colon into the numeric offset by hand +# and keep the ISO-8601 shape the previous stamps used. +BUILD_TIME="$(date +%Y-%m-%dT%H:%M:%S%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/')" +BUILD_HOST="$(hostname 2>/dev/null || echo unknown)" + +# java.util.Properties treats \ : = # ! specially in values; escape what can occur +# here. Timestamps and URLs carry ':', commit subjects can carry anything. +esc() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/:/\\:/g' -e 's/=/\\=/g' -e 's/#/\\#/g' -e 's/!/\\!/g' +} + +OUT_DIR="$(dirname "$OUT")" +mkdir -p "$OUT_DIR" + +{ + echo "#Generated by scripts/write_git_properties.sh" + echo "git.branch=$(esc "$BRANCH")" + echo "git.build.host=$(esc "$BUILD_HOST")" + echo "git.build.time=$(esc "$BUILD_TIME")" + echo "git.build.user.email=$(esc "$BUILD_USER_EMAIL")" + echo "git.build.user.name=$(esc "$BUILD_USER_NAME")" + echo "git.commit.id=$(esc "$COMMIT_ID")" + echo "git.commit.id.abbrev=$(esc "$COMMIT_ABBREV")" + echo "git.commit.id.describe=$(esc "$DESCRIBE")" + echo "git.commit.message.short=$(esc "$COMMIT_MESSAGE_SHORT")" + echo "git.commit.time=$(esc "$COMMIT_TIME")" + echo "git.commit.user.email=$(esc "$COMMIT_USER_EMAIL")" + echo "git.commit.user.name=$(esc "$COMMIT_USER_NAME")" + echo "git.dirty=$DIRTY" + echo "git.remote.origin.url=$(esc "$REMOTE_URL")" + echo "git.tags=$(esc "$TAGS")" +} > "$OUT" + +# Legacy stamps written by git-commit-id into the source tree. They are build +# output (gitignored), and obp-commons' copy would otherwise keep shipping a +# stale git.properties that can shadow this one on the runtime classpath. +if [[ -n "$REPO_ROOT" ]]; then + rm -f "$REPO_ROOT/obp-api/src/main/resources/git.properties" \ + "$REPO_ROOT/obp-commons/src/main/resources/git.properties" \ + "$REPO_ROOT/obp-commons/target/classes/git.properties" 2>/dev/null || true +fi + +exit 0 From 99908d02bc96f0c68fba6b24507c3c5d71447e5c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 16:26:54 +0000 Subject: [PATCH 44/63] docs: forbid tool names and filler phrasing in commit messages --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index aa74ad264c..177a1fb33a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ ## Working Style - Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve. - Never add `Co-Authored-By` trailers to commit messages. +- Commit messages, code comments, and PR titles/descriptions: no AI/tool names (Claude, GPT, Copilot, etc.), no AI-typical filler phrasing ("Certainly!", "I'll help you with..."), no emoji, no "AI-generated"/"LLM" labels. Use plain Conventional Commits style (`fix:`, `feat:`, `refactor:`, ...) and set commit author/committer to the actual person directing the work. - **Goal is full http4s migration** — eliminate Lift Web and all deprecated libraries entirely. Treat Lift code as temporary scaffolding to be removed, not maintained. When fixing bugs or adding features, always prefer the http4s path. - **Versioning is tech-agnostic** — API version numbers reflect API signature changes (new/changed fields, new behaviour), never the underlying framework. A framework migration (Lift → http4s) happens in-place at the existing version; it does not justify a version bump. - **`APIMethodsXYZ.scala` (Lift) files are the source of truth for migration.** The commented-out Lift ResourceDocs and endpoints inside each `APIMethodsXYZ.scala` are the canonical reference for what the http4s version should match: URL templates, verb casing, summaries, descriptions, example bodies, error lists, tags. **Do NOT edit these files to make the parity audit pass.** The audit compares http4s against the Lift source-of-truth — when it flags a diff, the fix is to either (a) update http4s to match Lift, or (b) document the difference at the http4s site as a known intentional drift (e.g. a placeholder rename for `ResourceDocMatcher` middleware, or an upstream-driven case-class shape change). Rewriting the Lift comments to match http4s runs the comparison backwards and destroys the historical record. See `scripts/check_lift_http4s_resource_doc_parity.py` for the audit, and `scripts/rehydrate_resource_docs.py` / `scripts/restore_resource_doc_bodies.py` for the canonical Lift → http4s restoration tools. From c471741f3aeed6e22d5e2ada7c66ffbdce52ceaf Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 3 Aug 2026 16:44:36 +0200 Subject: [PATCH 45/63] refactor: share the access pipeline between anonymous and application access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anonymousAccess and applicationAccess opened with the same four stages copied verbatim — resolve the user and session, verify the signed request, run the Berlin Group checks, apply rate limiting — down to the variable names and the rate_limiting.exclude_endpoints default. Extract them into accessPipeline so each method is left with only the part that is actually its own, and fold the four repeated url/verb/body/reqHeaders locals into requestPartsOf. No behaviour change. The two terminal steps are preserved exactly: anonymousAccess still runs the afterAuthenticateInterceptResult step and turns a Failure into a 401 that keeps the original message, while applicationAccess still answers ApplicationNotIdentified for anything that is not "no error and a known Consumer". applicationAccess is deliberately NOT expressed as anonymousAccess(cc) map {...}. Stacking them would change behaviour twice over: anonymousAccess terminates a Failure through fullBoxOrException, which throws, so an outer map never runs and applicationAccess would lose its chance to answer ApplicationNotIdentified; and app-mode callers would additionally inherit the intercept step, which is specific to the anonymous path. The two tails are genuinely different decisions about the same pipeline output, not one refining the other. Its terminal match now spells out all three cases instead of letting Full fall into the catch-all. That was correct only because Box.~> is identity on Full and fullBoxOrException forwards Full untouched — two details a hundred lines apart that the code should not have to depend on. Verified with the full local suite on JDK 25: 4 shards, 374 suites, 0 failures and 0 errors, including every suite that asserts ApplicationNotIdentified (v5_0_0.ConsentRequestTest, v5_1_0.ConsumerTest, v5_1_0.VRPConsentRequestTest, v6_0_0.DynamicEntityTest, v6_0_0.EndpointAuthModeTest, v6_0_0.GetOidcClientTest, v6_0_0.VerifyOidcClientTest) and the ATM endpoints that pick between the two methods in a single expression. --- .../main/scala/code/api/util/APIUtil.scala | 71 +++++++++---------- 1 file changed, 35 insertions(+), 36 deletions(-) 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 95fbee0c0d..50c383f6b4 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3046,23 +3046,32 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } /** - * This function is used to introduce Rate Limit at an unauthorized endpoint + * The parts of a request that both [[JwsUtil.verifySignedRequest]] and + * [[BerlinGroupCheck.validate]] take, in the order they take them. + */ + private def requestPartsOf(result: (Box[User], Option[CallContext])) = { + val callContext = result._2 + ( + callContext.flatMap(_.httpBody), + callContext.map(_.verb).getOrElse("None"), + callContext.map(_.url).getOrElse("None"), + callContext.map(_.requestHeaders).getOrElse(Nil) + ) + } + + /** + * The pre-processing shared by [[anonymousAccess]] and [[applicationAccess]]: resolve the + * user and session, verify the signed request, run the Berlin Group checks and apply rate + * limiting. Each caller decides on its own what to make of the outcome. * @param cc The call context of an request - * @return Failure in case we exceeded rate limit */ - def anonymousAccess(cc: CallContext): OBPReturnType[Box[User]] = { - getUserAndSessionContextFuture(cc) map { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) + private def accessPipeline(cc: CallContext): OBPReturnType[Box[User]] = { + getUserAndSessionContextFuture(cc) map { result => + val (body, verb, url, reqHeaders) = requestPartsOf(result) // Verify signed request JwsUtil.verifySignedRequest(body, verb, url, reqHeaders, result) } flatMap { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) + val (body, verb, url, reqHeaders) = requestPartsOf(result) // Berlin Group checks BerlinGroupCheck.validate(body, verb, url, reqHeaders, result) } map { @@ -3072,7 +3081,16 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case Some(functionName) if excludeFunctions.exists(_ == functionName) => result case _ => RateLimitingUtil.underCallLimits(result) } - } map { + } + } + + /** + * This function is used to introduce Rate Limit at an unauthorized endpoint + * @param cc The call context of an request + * @return Failure in case we exceeded rate limit + */ + def anonymousAccess(cc: CallContext): OBPReturnType[Box[User]] = { + accessPipeline(cc) map { it => val callContext = it._2 @@ -3107,32 +3125,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ * @return Tuple (User, Call Context) */ def applicationAccess(cc: CallContext): Future[(Box[User], Option[CallContext])] = - getUserAndSessionContextFuture(cc) map { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) - // Verify signed request if need be - JwsUtil.verifySignedRequest(body, verb, url, reqHeaders, result) - } flatMap { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) - // Berlin Group checks - BerlinGroupCheck.validate(body, verb, url, reqHeaders, result) - } map { - result => - val excludeFunctions = getPropsValue("rate_limiting.exclude_endpoints", "root,getOAuth2ServerWellKnown").split(",").toList - cc.resourceDocument.map(_.partialFunctionName) match { - case Some(functionName) if excludeFunctions.exists(_ == functionName) => result - case _ => RateLimitingUtil.underCallLimits(result) - } - } map { result => + accessPipeline(cc) map { result => result._1 match { + case Full(_) => // The user is known, so the application is too + result case Empty if result._2.flatMap(_.consumer).isDefined => // There is no error and Consumer is defined result - case _ => + case _ => // No Consumer, or a Failure whose reason we deliberately do not disclose here ( fullBoxOrException(result._1 ~> APIFailureNewStyle(ApplicationNotIdentified, 401, Some(cc.toLight))), result._2 From 6060f425244168f2083aae0680de7b3b320d7d6f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 3 Aug 2026 16:33:40 +0200 Subject: [PATCH 46/63] fix: don't bind a Berlin Group consent to the lodging consumer's own pseudo-user A client-credentials token still resolves cc.user to an auto-vivified pseudo-user rather than leaving it Empty: OAuth2.getOrCreateResourceUser maps the JWT's `sub` onto idGivenByProvider, and in a client-credentials grant `sub` is the caller's own client id. POST /berlin-group/v1.3/consents carried that value straight through into createBerlinGroupConsent's user param, so the consent was owned by the TPP's own pseudo-identity instead of being left unowned until the PSU authorises it. That owner is neither blank nor the PSU, so both disjuncts of the guard on GET /obp/v5.1.0/user/current/consents/CONSENT_ID fail and the PSU is told OBP-35001 Consent not found for a consent that exists and is theirs to approve -- which is where the Berlin Group redirect-SCA journey has been stopping. The blank-owner branch added for unclaimed consents cannot help, because a pseudo-user owner is not blank. Leaving the owner unset restores the intended sequence: the consent is lodged unowned, the PSU can see it at SCA time, and the native authorisations endpoint pair binds them on a correct OTP. Nothing downstream needs the user at creation -- createBerlinGroupConsentJWT gives every consent a random `sub` regardless, which is what applyConsentRules resolves into the per-consent principal, and the user param only feeds the createdByUserId claim and an auth-context copy that the authorisation step writes again. This is the same defect and the same fix the UK endpoints took in def2da792; the Berlin Group creation path was missed at the time. The filter is copied rather than extracted into a shared helper on purpose: the UK sites and ConsentUtil are being edited on another branch, and a shared helper would collide there for no behavioural gain. The regression test drives POST /consents on a session whose user is keyed on the consumer's own client key, reproducing the client-credentials CallContext the OAuth1 test harness cannot mint directly. It was confirmed failing before the fix ("false was not true" on the unowned assertion) and passes after; a second scenario pins that a genuine PSU session is still recorded as the owner. --- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 11 +- .../AccountInformationServiceAISApiTest.scala | 103 +++++++++++++++++- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala index 711e63b4e9..61a18e5da4 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 @@ -68,10 +68,13 @@ object Http4sBGv13AIS extends MdcLoggable { EndpointHelpers.executeFutureCreated(req) { val cc = req.callContext val callContext = Some(cc) - val createdByUser: Option[User] = cc.user match { - case Full(user) => Some(user) - case _ => None - } + // A pure client-credentials token still resolves cc.user to an auto-vivified + // pseudo-user (idGivenByProvider == the calling consumer's own client key) rather than + // leaving it Empty -- that pseudo-user is not a PSU, so it must not become the + // consent's owner (it would permanently block the real PSU's authorise-time + // ConsentDoesNotMatchUser check). Only carry a genuine PSU session through. + val createdByUser: Option[User] = cc.user.toOption + .filterNot(u => cc.consumer.map(_.key.get).contains(u.idGivenByProvider)) for { _ <- passesPsd2Aisp(callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $PostConsentJson " diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index 140999ffc1..f70fdb0535 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -13,17 +13,23 @@ import code.api.util.Consent import code.api.util.ErrorMessages._ import code.api.v4_0_0.PostViewJsonV400 import code.consent.{ConsentStatus, ConsentTrait, Consents} -import code.model.dataAccess.BankAccountRouting +import code.model.TokenType.Access +import code.model.UserX +import code.model.dataAccess.{BankAccountRouting, ResourceUser} import code.setup.{APIResponse, DefaultUsers} +import code.token.Tokens import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage import com.openbankproject.commons.model.enums.AccountRoutingScheme import org.json4s.native.Serialization.write import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan import org.scalatest.Tag import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Date import scala.concurrent.Await import scala.concurrent.duration._ @@ -883,4 +889,99 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit } } + // The consent body used by the ownership scenarios below: one account, addressed by the first + // IBAN routing in the test data — the same shape createUnclaimedBerlinGroupConsent() builds. + def bgConsentPostBody(): PostConsentJson = { + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + val acountRoutingIban = accountsRoutingIban.head + PostConsentJson( + access = ConsentAccessJson( + accounts = Option(List(ConsentAccessAccountsJson( + iban = Some(acountRoutingIban.accountRouting.address), + bban = None, + pan = None, + maskedPan = None, + msisdn = None, + currency = None, + ))), + balances = None, + transactions = None, + availableAccounts = None, + allPsd2 = None + ), + recurringIndicator = true, + validUntil = getNextMonthDate(), + frequencyPerDay = 4, + combinedServiceIndicator = Some(false) + ) + } + + // A client_credentials token carries the caller's own client id in `sub`, and OAuth2's + // getOrCreateResourceUser turns `sub` into idGivenByProvider — so such a token resolves cc.user to + // an auto-vivified pseudo-user keyed on the consumer's client key rather than leaving it Empty. + // The OAuth1-signed test harness cannot mint that token, so build the same CallContext shape + // directly: a user whose idGivenByProvider IS testConsumer's client key, plus an access token for + // it issued under testConsumer. Signing with this pair gives POST /consents exactly what a + // client_credentials TPP gives it — cc.user.idGivenByProvider == cc.consumer.key. + lazy val pseudoUserOfTestConsumer: ResourceUser = + UserX.findByProviderId(provider = defaultProvider, idGivenByProvider = testConsumer.key.get) + .map(_.asInstanceOf[ResourceUser]) + .getOrElse { + UserX.createResourceUser( + provider = defaultProvider, + providerId = Some(testConsumer.key.get), + createdByConsentId = None, + name = Some(testConsumer.key.get), + email = Some("pseudo.user.of.test.consumer@example.com"), + userId = None, + company = Some("Tesobe GmbH") + ).openOrThrowException("test pseudo user creation failed") + } + + lazy val pseudoUserToken = Tokens.tokens.vend.createToken( + Access, + Some(testConsumer.id.get), + Some(pseudoUserOfTestConsumer.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new Date(System.currentTimeMillis())), + None + ).openOrThrowException("test pseudo user token creation failed") + + // Same consumer as user1, different token: cc.consumer is testConsumer, cc.user is the pseudo-user. + lazy val clientCredentialsSession = Some(consumer, Token(pseudoUserToken.key.get, pseudoUserToken.secret.get)) + + feature(s"BG v1.3 - $createConsent consent ownership") { + scenario("A consent lodged on a client-credentials session is left unowned, not bound to the consumer's own pseudo-user", BerlinGroupV1_3, createConsent) { + val requestPost = (V1_3_BG / "consents").POST <@ (clientCredentialsSession) + val response: APIResponse = makePostRequest(requestPost, write(bgConsentPostBody())) + + Then("We should get a 201") + response.code should equal(201) + val consentId = response.body.extract[PostConsentResponseJson].consentId + + Then("The consent must be left unowned — a pseudo-user owner is neither blank nor the PSU, so it " + + "would fail the mUserId guard on GET /obp/v5.1.0/user/current/consents/CONSENT_ID and hide the " + + "consent from the real PSU at SCA time (OBP-35001)") + val createdConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + Option(createdConsent.userId).forall(_.isBlank) should be (true) + createdConsent.status should be (ConsentStatus.received.toString) + } + + scenario("A consent lodged on a genuine PSU session is still owned by that PSU", BerlinGroupV1_3, createConsent) { + val requestPost = (V1_3_BG / "consents").POST <@ (user1) + val response: APIResponse = makePostRequest(requestPost, write(bgConsentPostBody())) + + Then("We should get a 201") + response.code should equal(201) + val consentId = response.body.extract[PostConsentResponseJson].consentId + + Then("Filtering out the consumer's pseudo-user must not drop a real PSU session (DirectLogin/OAuth1)") + val createdConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + createdConsent.userId should be (resourceUser1.userId) + } + } + } \ No newline at end of file From 5323d5b595c9d9ff4c5552846d99db2e6f9dc613 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 3 Aug 2026 17:42:21 +0200 Subject: [PATCH 47/63] fix: declare the auth mode UK consent lodging actually uses (#56) * fix: declare the auth mode UK consent lodging actually uses Lodging an account-access consent is a client-credentials call: the TPP is authenticated as an application and no PSU exists yet -- the PSU is bound later, during the authorise ceremony. Both UK handlers already say so, rejecting only a fully anonymous request rather than demanding a user. Their ResourceDocs did not. With no authMode they take the UserOnly default, which sends ResourceDocMiddleware down anonymousAccess, and that returns 401 for any request carrying no user. Nothing breaks today only because of a separate defect: OAuth2 token parsing resolves `sub` to a ResourceUser without asking what kind of token it came from, and for a client-credentials grant `sub` is the client_id (RFC 9068). The caller is handed an auto-vivified user that is not a person, cc.user is never Empty, and the UserOnly path never fires. The two hand-written guards inside these handlers -- the anti-anonymous check, and the filterNot that stops that pseudo-user becoming the consent's owner -- exist for the same reason and stay until the token defect is fixed. This is the first of three steps and deliberately the one that lands first: fixing the token defect before the contract is stated would make these endpoints 401 the very flow the standard requires them to serve. Behaviour is unchanged on every path that reaches them today. isAppMode only chooses applicationAccess over anonymousAccess; both call getUserAndSessionContextFuture first, so the consent principal hook at the end of authentication still runs, and both pass a Full user through untouched. The case that changes is one that cannot occur yet: a valid consumer with no user now reaches the handler instead of being rejected. That is the whole point -- it is the safety net for step three. One difference is real and worth stating plainly: a fully anonymous request still gets 401, but reports ApplicationNotIdentified rather than AuthenticatedUserIsRequired, and the ResourceDoc gains that error plus a note describing the auth mode. The Berlin Group twin, Http4sBGv13AIS.createConsent, has always behaved this way; UK now matches it. Both docs get a scenario pinning the auth mode, because nothing else would catch a silent revert to the default -- the endpoints would keep working right up until the day the token defect is fixed. * fix: hide consent principals from the v6.0.0 user search too The filter that keeps a consent's own principal out of GET /users was added to LiftUsers.getUsersCommon, which backs the v2.1.0 and v3.0.0 list endpoints. The v6.0.0 search never goes through it -- getUsersV600F builds its own query in DoobieUserQueries -- so it still returned one row per consent ever granted, each with no username and no email. Same predicate, and in the WHERE clause rather than over the result, so it composes with LIMIT/OFFSET instead of returning short pages. Users auto-vivified for an application are deliberately left visible. They look similar -- no real name, one row that stands for something other than a person -- but the reason for hiding consent principals was volume, one per consent granted, and that does not carry over: there is at most one per application. Against that, GET /users is role-gated and often read precisely to audit who holds access, and these rows can carry entitlements like any other. Hiding them would trade a little noise for an audit blind spot, and would also erase the only readily visible trace of the token-parsing defect that mints them. They should stop being created, not stop being shown. --- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 9 ++++++++- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 9 ++++++++- .../main/scala/code/users/DoobieUserQueries.scala | 9 +++++++++ obp-api/src/main/scala/code/users/LiftUsers.scala | 2 ++ .../v3_1_0/UKOpenBankingV310AisTests.scala | 12 +++++++++++- .../UKOpenBankingV401AccountInfoTests.scala | 15 ++++++++++++++- 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 654de87498..46a4129ee3 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -5,7 +5,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} @@ -169,6 +169,13 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { }"""), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access") :: Nil, + // Consent lodging is a client-credentials call: the TPP is authenticated as an app, and there + // is no PSU yet. The handler above already says so; this makes the ResourceDoc say it too. + // Without it the doc defaults to UserOnly, which sends the middleware down anonymousAccess and + // 401s any request that carries no user -- so the endpoint only works today because OAuth2 + // token parsing auto-vivifies a user for a client-credentials token. Matches the Berlin Group + // twin (Http4sBGv13AIS.createConsent), which has always been UserOrApplication. + authMode = UserOrApplication, http4sPartialFunction = Some(createAccountAccessConsents) ) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 3bcf7778ce..e5b4d678ed 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -5,7 +5,7 @@ import cats.effect.IO import code.api.APIFailureNewStyle import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, parseIso8601OrDayDate} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, UserOrApplication, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats @@ -175,6 +175,13 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { parseBody(EX_createAccountAccessConsents), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access Consents") :: Nil, + // Consent lodging is a client-credentials call: the TPP is authenticated as an app, and there + // is no PSU yet. The handler above already says so; this makes the ResourceDoc say it too. + // Without it the doc defaults to UserOnly, which sends the middleware down anonymousAccess and + // 401s any request that carries no user -- so the endpoint only works today because OAuth2 + // token parsing auto-vivifies a user for a client-credentials token. Matches the Berlin Group + // twin (Http4sBGv13AIS.createConsent), which has always been UserOrApplication. + authMode = UserOrApplication, http4sPartialFunction = Some(createAccountAccessConsents) ) diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala index 0bd3f4304a..58424cca5a 100644 --- a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -134,6 +134,14 @@ object DoobieUserQueries { Fragment.empty } + // Users a consent minted for itself are excluded here exactly as LiftUsers.getUsersCommon + // excludes them from the older list endpoints — see the comment there for why they are not + // people. That filter was only ever applied to the Lift path, so this newer search endpoint + // still listed them. Applied in SQL for the same reason: filtering after LIMIT/OFFSET returns + // short pages. + val notMintedByAConsentFilter: Fragment = + fr"AND (ru.createdbyconsentid IS NULL OR ru.createdbyconsentid = '')" + // role_name/bank_id via EXISTS subquery against mappedentitlement — keeps // the outer row count correct (no duplicates when a user has many entitlements). val roleFilter: Fragment = roleName match { @@ -160,6 +168,7 @@ object DoobieUserQueries { deletedFilter ++ lockedFilter ++ roleFilter ++ + notMintedByAConsentFilter ++ orderBy ++ fr"LIMIT $limit OFFSET $offset") .query[UserSearchRow] diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index 4e74a0fbf5..39113489ef 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -185,6 +185,8 @@ object LiftUsers extends Users with MdcLoggable{ // Filtered in SQL rather than after the fact, so it composes with the limit/offset above: a // filter applied to an already-paginated result returns short pages, which is exactly the // defect the ?locked= path below has. + // + // The v6.0.0 search path applies the same predicate -- see DoobieUserQueries.getUsers. val notMintedByAConsent = BySql[ResourceUser]( "(createdbyconsentid IS NULL OR createdbyconsentid = '')", IHaveValidatedThisSQL("hongwei", "2026-08-01")) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala index b607a6ddae..20da0faa34 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala @@ -1,9 +1,10 @@ package code.api.UKOpenBanking.v3_1_0 -import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, UserOrApplication, buildOperationId} import code.api.util.ErrorMessages.ConsentDoesNotMatchConsumer import code.consent.Consents import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion import org.scalatest.Tag /** @@ -51,6 +52,15 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV310) { postUnauthed("{}", "account-access-consents").code should equal(401) } + // See the twin scenario in UKOpenBankingV401AccountInfoTests for why this is pinned: lodging is + // a client-credentials call with no PSU, and the ResourceDoc default (UserOnly) would 401 it as + // soon as a client-credentials token stops auto-vivifying a user. + scenario("ResourceDoc declares UserOrApplication so a PSU-less TPP call is not rejected", UKOpenBankingV310) { + val docs = ResourceDoc.getResourceDocs( + List(buildOperationId(ApiVersion.ukOpenBankingV31, "createAccountAccessConsents"))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } } feature("UKOB v3.1 DELETE /account-access-consents/CONSENT_ID") { scenario("authenticated", UKOpenBankingV310) { diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 0084a1cd7f..c210608603 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -1,13 +1,14 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.Constant -import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, UserOrApplication, buildOperationId} import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentExpiredIssue, ConsentIdClaimMissing} import code.api.util.{CallContext, CertificateUtil, Consent} import code.consent.{ConsentStatus, Consents} import code.model.UserExtended import code.views.Views import com.openbankproject.commons.model.{BankIdAccountId, ErrorMessage, ViewId} +import com.openbankproject.commons.util.ApiVersion import net.liftweb.common.{Failure, Full} import org.json4s._ import org.scalatest.Tag @@ -141,6 +142,18 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { postUnauthed(consentPostBody, "aisp", "account-access-consents").code should equal(401) } + // Lodging a consent is a client-credentials call: the TPP is authenticated as an app and no PSU + // exists yet. UserOnly (the ResourceDoc default) sends ResourceDocMiddleware down anonymousAccess, + // which 401s a request carrying no user. Pinned here because nothing else would notice the doc + // silently reverting to the default -- the endpoint would keep working for as long as OAuth2 + // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day + // that stops. + scenario("ResourceDoc declares UserOrApplication so a PSU-less TPP call is not rejected", UKOpenBankingV401AccountInfo) { + val docs = ResourceDoc.getResourceDocs( + List(buildOperationId(ApiVersion.ukOpenBankingV401, "createAccountAccessConsents"))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } scenario("all three datetime fields omitted -> 201, open-ended (no expiry/date restriction)", UKOpenBankingV401AccountInfo) { val bodyWithoutDates = """{ From 4d21dc6cfabd745c9fcb82ed304fbb4510324773 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 4 Aug 2026 10:41:58 +0200 Subject: [PATCH 48/63] fix: give each local test run its own Redis key namespace (#58) Ports and the H2 database are already isolated per run, but Redis is not: every checkout on a developer machine talks to the same 127.0.0.1:6379. OBP_API_INSTANCE_ID feeds Constant.getGlobalCacheNamespacePrefix, which prefixes every cache key with "{api_instance_id}_{runmode}_". The runner set it to "shard_${n}", which is identical in every checkout, so two concurrent runs shared one namespace. That matters because LocalMappedConnectorTestSetup.wipeTestData deletes the whole namespace by prefix after EVERY test: one run's teardown was deleting another run's live rate-limit counters, once per test. Nothing failed, because the rate-limit suites seed their counters immediately before asserting -- but that is luck, not isolation. Mixing in the already-allocated random port makes the namespace unique per run, so a teardown only ever deletes its own keys. Measured on a shared 127.0.0.1:6379 by sampling live keys: before 4 prefixes shard_1_test_ .. shard_4_test_ (shared by all runs) after 8 prefixes shard_1_26053_test_, shard_1_29629_test_, ... all 8 observed coexisting in one snapshot, disjoint Regression: run_tests_parallel.sh 3271 tests, 0 failures, 0 errors, both standalone and with two checkouts running the full suite concurrently. No production code is touched, and CI is unaffected: the workflows never set OBP_API_INSTANCE_ID, so they keep using the "1_final" default from ServerSetup. Nothing in the tree depends on the "shard_N" shape -- no test, log parser or script matches on it. This does not address cross-checkout resource-doc cache sharing: those keys are also deterministic, but that hazard was not reproducible in testing (wipeTestData clears them after every test, leaving only a very narrow window) and is tracked separately. --- run_tests_parallel.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/run_tests_parallel.sh b/run_tests_parallel.sh index 56c8d5107f..fd952014ca 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -294,6 +294,17 @@ run_shard() { # ports (both test servers bind a real socket; see the port-allocation block). # Tests only, no recompile (the compile already happened in the pre-compile step). # ${TIMEOUT_BIN} 1200: hard-kill after 20 min to prevent Pekko non-daemon threads from hanging. + # OBP_API_INSTANCE_ID feeds Constant.getGlobalCacheNamespacePrefix, which prefixes every + # Redis cache key with "{api_instance_id}_{runmode}_". Ports and the H2 database are already + # isolated per run, but Redis is not: every checkout on this machine talks to the same + # 127.0.0.1:6379. A plain "shard_${n}" is therefore identical in every checkout, so two + # concurrent runs share one key namespace -- and LocalMappedConnectorTestSetup.wipeTestData + # deletes that whole namespace after EVERY test. Run A's teardown was wiping run B's live + # rate-limit counters, once per test. Nothing failed because the rate-limit suites seed their + # counters immediately before asserting, but that is luck, not isolation. Mixing in the + # already-allocated random port makes the namespace unique per run, so a teardown only ever + # deletes its own keys. Keys are still cleaned up: wipeTestData removes the whole prefix at + # the end of every test, so unique namespaces do not accumulate garbage in the shared Redis. MAVEN_OPTS="$MVN_OPTS" \ OBP_TESTS_PORT="${port}" \ OBP_HOSTNAME="http://localhost:${port}" \ @@ -301,7 +312,7 @@ run_shard() { OBP_MAIL_TEST_MODE="true" \ OBP_DYNAMIC_CODE_SANDBOX_PERMISSIONS='[new java.net.NetPermission("specifyStreamHandler"), new java.lang.reflect.ReflectPermission("suppressAccessChecks"), new java.lang.RuntimePermission("getenv.*"), new java.util.PropertyPermission("cglib.useCache", "read"), new java.util.PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"), new java.util.PropertyPermission("cglib.debugLocation", "read"), new java.lang.RuntimePermission("accessDeclaredMembers"), new java.lang.RuntimePermission("getClassLoader")]' \ OBP_ALLOW_USER_GENERATED_SCALA_CODE="true" \ - OBP_API_INSTANCE_ID="shard_${n}" \ + OBP_API_INSTANCE_ID="shard_${n}_${port}" \ "$TIMEOUT_BIN" 1200 mvn scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ "-DwildcardSuites=${filter}" \ > "$log" 2>&1 From f697dd17015b3474045b9c0c961129573ebc3248 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 4 Aug 2026 11:50:51 +0200 Subject: [PATCH 49/63] fix: let resource-doc and swagger caches survive an unreachable Redis (#59) The product caches in Caching already route through tryGet/trySet, whose comment states the intent plainly: if Redis is unreachable, treat it as a miss and recompute instead of failing the whole request. The resource-doc and swagger caches sit directly above them in the same file and did not -- they called Redis.use bare, and Redis.use throws rather than returning None. A Redis blip therefore turned every /resource-docs, /swagger, OpenAPI and message-docs request into a 500. These are exactly the documents API Explorer and Portal load on startup, so the blast radius is the whole developer-facing surface. Route all four get/set pairs through the same wrappers and give them explicit return types matching the product caches (Option[String] / Unit). Every call site already discarded the set return value, so narrowing it to Unit is source-compatible. Measured by running the suite against a dead Redis port (OBP_CACHE_REDIS_PORT=6399), which makes every Redis call fail: before 3273 tests, 135 failures after 3273 tests, 39 failures The 96 that disappear are the ones served from these caches: ResourceDocsTest (55), V7ResourceDocsAggregationTest (12), SwaggerDocsTest (11), MessageDocsJsonSchemaTest (8), DynamicEndpointsTest (6), ResourceDocsTechnologyTest (2), GetMessageDocsSwaggerTest (1) and Http4sServerIntegrationTest (1). With a healthy Redis the suite is unchanged: 3273 tests, 0 failures. The remaining 39 are other Redis dependencies, untouched here: rate limiting (23), the endpoints that exist to inspect Redis itself (12), and four consent/dauth scenarios that assert on an error message rather than a status code -- those still reject the request correctly, just with different wording. Rate limiting is unaffected in another way worth stating: RateLimitingUtil calls Redis.use directly rather than going through Caching, so the "REDIS_UNAVAILABLE" branch in getCounterState stays dead code. Making it live would mean changing Redis.use itself, which every caller shares. --- .../main/scala/code/api/cache/Caching.scala | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/obp-api/src/main/scala/code/api/cache/Caching.scala b/obp-api/src/main/scala/code/api/cache/Caching.scala index 4a87556ce3..74dbee5a2f 100644 --- a/obp-api/src/main/scala/code/api/cache/Caching.scala +++ b/obp-api/src/main/scala/code/api/cache/Caching.scala @@ -57,38 +57,36 @@ object Caching extends MdcLoggable { } } - def getDynamicResourceDocCache(key: String) = { - use(JedisMethod.GET, (DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL)) - } - - def setDynamicResourceDocCache(key:String, value: String)= { - use(JedisMethod.SET, (DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL), Some(value)) - } + // Resource-doc / swagger caches. These go through the same fail-safe wrappers as the product + // caches below: the cache is an optimisation, and an unreachable Redis must degrade to a miss + // (recompute and serve) rather than turn every /resource-docs, /swagger and message-docs request + // into a 500. These are the documents API Explorer and Portal load on startup, so a Redis blip + // used to take the whole surface down. + def getDynamicResourceDocCache(key: String): Option[String] = + tryGet(DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL) - def getStaticResourceDocCache(key: String) = { - use(JedisMethod.GET, (STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL)) - } - - def setStaticResourceDocCache(key:String, value: String)= { - use(JedisMethod.SET, (STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL), Some(value)) - } + def setDynamicResourceDocCache(key: String, value: String): Unit = + trySet(DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL, value) - def getAllResourceDocCache(key: String) = { - use(JedisMethod.GET, (ALL_RESOURCE_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL)) - } - - def setAllResourceDocCache(key:String, value: String)= { - use(JedisMethod.SET, (ALL_RESOURCE_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL), Some(value)) - } + def getStaticResourceDocCache(key: String): Option[String] = + tryGet(STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL) - def getStaticSwaggerDocCache(key: String) = { - use(JedisMethod.GET, (STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL)) - } - - def setStaticSwaggerDocCache(key:String, value: String)= { - use(JedisMethod.SET, (STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL), Some(value)) - } - // Fail-safe wrappers around Redis.use for product caches. If Redis is unreachable (dev without a + def setStaticResourceDocCache(key: String, value: String): Unit = + trySet(STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL, value) + + def getAllResourceDocCache(key: String): Option[String] = + tryGet(ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL) + + def setAllResourceDocCache(key: String, value: String): Unit = + trySet(ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL, value) + + def getStaticSwaggerDocCache(key: String): Option[String] = + tryGet(STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL) + + def setStaticSwaggerDocCache(key: String, value: String): Unit = + trySet(STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL, value) + + // Fail-safe wrappers around Redis.use. If Redis is unreachable (dev without a // running Redis, transient failure, etc.) we treat it as a miss and recompute instead of failing // the whole request. private def tryGet(prefix: String, key: String, ttlSeconds: Int): Option[String] = From 384ee651f767c33276803a82cd3009845fb0557b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 4 Aug 2026 12:36:38 +0200 Subject: [PATCH 50/63] fix: let an AISP poll and revoke its own UK consent (#57) The standard is explicit about who calls these. In the Endpoints table of account-access-consents, in both v3.1 and v4.0.1, all three consent endpoints carry Grant Type "Client Credentials", and the prose repeats it for each of GET and DELETE: "Prior to calling the API, the AISP must have an access token issued by the ASPSP using a client credentials grant." GET is described as retrieving a consent "that they have created"; DELETE is what the AISP does after the PSU has revoked consent with the AISP, not something the PSU drives at the API. So there is no PSU in the session for any of them, by design. The identity that matters is the AISP -- the Consumer the consent was lodged under. All four consent-by-id endpoints -- v3.1 and v4.0.1, GET and DELETE -- were built as if there were. They took the UserOnly ResourceDoc default, they were written with withUser/withUserDelete, which require a user to be present, and their ownership rule, in four verbatim copies, keyed on the caller's user id: Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId That rule is off-spec, not a baseline this change relaxes. It survived only because OAuth2 token parsing hands a client-credentials caller an auto-vivified user that is not a person, so a user id was always there to compare -- the defect described in the previous change in this sequence. The consent's own Consumer, which the copies did check, is the thing the standard actually names. The rule moves to Consent.checkUKConsentAccess, next to validateUKConsentPermissions and for the same reasons: one definition instead of four for a rule whose subtlety is the whole point, and a shape that can be tested without standing up a request. The handlers move to executeAndRespond and executeDelete, and the four ResourceDocs declare UserOrApplication. For a caller with no PSU -- the only caller the standard describes -- access is now decided by the Consumer alone: the AISP that lodged the consent may read and revoke it whatever its status, and no other Consumer may. Previously an authorised consent reached this way was refused with ConsentDoesNotMatchUser. The user check is kept, unchanged, for a caller that does present a PSU. OBP allows credentials the standard does not describe here, and for those the stricter rule still applies: a session acting as one PSU cannot reach another PSU's consent, and the lodging TPP cannot use a PSU session to do it either. That path is a superset of the standard, never a way around it. The rule is unit-tested rather than driven over HTTP because the test framework signs with OAuth1, which always attaches a user: there is no way to make a genuinely PSU-less request from a test. The ResourceDocs get their auth mode pinned separately, since that is what lets such a request reach the handler at all, and nothing else would notice a revert to the default until the token defect is fixed. --- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 52 ++++---- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 52 ++++---- .../scala/code/api/util/ConsentUtil.scala | 47 ++++++++ .../UKOpenBankingV401ConsentAccessTests.scala | 114 ++++++++++++++++++ 4 files changed, 205 insertions(+), 60 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 46a4129ee3..18d16ba435 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -8,7 +8,7 @@ import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBo import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.CallContext import code.api.util.{Consent, ConsentJWT, JwtUtil, NewStyle} @@ -181,26 +181,20 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV31Prefix` / "account-access-consents" / consentId => - EndpointHelpers.withUserDelete(req) { (user, cc) => + // Not withUserDelete: the standard has the AISP revoke its own consent with a + // client-credentials token, which carries no PSU. Consent.checkUKConsentAccess decides who + // may revoke it from whichever identity the session does carry. + EndpointHelpers.executeDelete(req) { cc => for { _ <- passesPsd2Aisp(Some(cc)) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } - // A consent already bound to a PSU may only be revoked by that same PSU -- otherwise any - // authenticated party could revoke another party's consent (IDOR, and the most severe of - // the two since it's destructive). Mirrors the identity contract enforced at consent - // authorise time (Http4s510: consent.userId == user.userId). - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { - Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId - } - // A consent not yet bound to any PSU may only be revoked by the Consumer that created - // it -- otherwise a second TPP could revoke a first TPP's still-pending consent. Once a - // PSU is bound the check above already governs, so this is a no-op then. - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { - !Option(consent.userId).forall(_.isBlank) || - Option(consent.consumerId).forall(_.isBlank) || - cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + _ <- Consent.checkUKConsentAccess( + consent.userId, consent.consumerId, + cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { + case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) + case None => Future.successful(true) } _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) @@ -221,29 +215,25 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { EmptyBody, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access") :: Nil, + // As with the POST that lodges the consent: revoking is a client-credentials call in the + // standard's AISP flow, so a PSU cannot be required. See Consent.checkUKConsentAccess. + authMode = UserOrApplication, http4sPartialFunction = Some(deleteAccountAccessConsentsConsentId) ) lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV31Prefix` / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (user, cc) => + // Not withUser -- see the DELETE twin above. + EndpointHelpers.executeAndRespond(req) { cc => for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } - // A consent already bound to a PSU may only be read by that same PSU -- otherwise any - // authenticated party could read another party's consent details (IDOR). Mirrors the - // identity contract enforced at consent authorise time (Http4s510: consent.userId == user.userId). - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { - Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId - } - // A consent not yet bound to any PSU may only be read by the Consumer that created it -- - // otherwise a second TPP could read a first TPP's still-pending consent by guessing its - // consentId. Once a PSU is bound the check above already governs, so this is a no-op then. - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { - !Option(consent.userId).forall(_.isBlank) || - Option(consent.consumerId).forall(_.isBlank) || - cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + _ <- Consent.checkUKConsentAccess( + consent.userId, consent.consumerId, + cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { + case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) + case None => Future.successful(true) } consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) @@ -309,6 +299,8 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { }"""), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access") :: Nil, + // As above: the AISP polls its own consent with a client-credentials token. + authMode = UserOrApplication, http4sPartialFunction = Some(getAccountAccessConsentsConsentId) ) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index e5b4d678ed..bfbc9bba3a 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -9,7 +9,7 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, UserOrApplicati import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle import code.api.util.{APIUtil, Consent, ConsentJWT, JwtUtil, NewStyle} @@ -221,24 +221,19 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (user, cc) => + // Not withUser: the standard has the AISP poll its own consent with a client-credentials + // token, which carries no PSU. Consent.checkUKConsentAccess decides who may read it from + // whichever identity the session does carry. + EndpointHelpers.executeAndRespond(req) { cc => for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } - // A consent already bound to a PSU may only be read by that same PSU -- otherwise any - // authenticated party could read another party's consent details (IDOR). Mirrors the - // identity contract enforced at consent authorise time (Http4s510: consent.userId == user.userId). - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { - Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId - } - // A consent not yet bound to any PSU may only be read by the Consumer that created it -- - // otherwise a second TPP could read a first TPP's still-pending consent by guessing its - // consentId. Once a PSU is bound the check above already governs, so this is a no-op then. - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { - !Option(consent.userId).forall(_.isBlank) || - Option(consent.consumerId).forall(_.isBlank) || - cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + _ <- Consent.checkUKConsentAccess( + consent.userId, consent.consumerId, + cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { + case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) + case None => Future.successful(true) } consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) @@ -269,32 +264,27 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { parseBody(EX_getAccountAccessConsentsConsentId), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access Consents") :: Nil, + // Same reasoning as the POST that lodges the consent: the AISP polls it with a + // client-credentials token, so a PSU cannot be required. See Consent.checkUKConsentAccess. + authMode = UserOrApplication, http4sPartialFunction = Some(getAccountAccessConsentsConsentId) ) private val EX_deleteAccountAccessConsentsConsentId: String = """{}""" lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUserDelete(req) { (user, cc) => + // Not withUserDelete -- see the GET twin above. + EndpointHelpers.executeDelete(req) { cc => for { _ <- passesPsd2Aisp(Some(cc)) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } - // A consent already bound to a PSU may only be revoked by that same PSU -- otherwise any - // authenticated party could revoke another party's consent (IDOR, and the most severe of - // the two since it's destructive). Mirrors the identity contract enforced at consent - // authorise time (Http4s510: consent.userId == user.userId). - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { - Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId - } - // A consent not yet bound to any PSU may only be revoked by the Consumer that created - // it -- otherwise a second TPP could revoke a first TPP's still-pending consent. Once a - // PSU is bound the check above already governs, so this is a no-op then. - _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchConsumer", 403, Some(cc)) { - !Option(consent.userId).forall(_.isBlank) || - Option(consent.consumerId).forall(_.isBlank) || - cc.consumer.map(_.consumerId.get).contains(consent.consumerId) + _ <- Consent.checkUKConsentAccess( + consent.userId, consent.consumerId, + cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { + case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) + case None => Future.successful(true) } _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) @@ -313,6 +303,8 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { parseBody(EX_deleteAccountAccessConsentsConsentId), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access Consents") :: Nil, + // As above: revoking is a client-credentials call in the standard's AISP flow. + authMode = UserOrApplication, http4sPartialFunction = Some(deleteAccountAccessConsentsConsentId) ) 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 a634c36b7e..b5886f1ac1 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1394,6 +1394,53 @@ object Consent extends MdcLoggable { } } + /** + * Decide whether a caller may read or revoke a UK account-access-consent, returning the reason to + * refuse with 403 or None when it is allowed. + * + * The standard names one caller, and it is not the PSU. In the Endpoints table of + * account-access-consents, in both v3.1 and v4.0.1, GET and DELETE carry Grant Type "Client + * Credentials", and the prose repeats it: "Prior to calling the API, the AISP must have an access + * token issued by the ASPSP using a client credentials grant." GET retrieves a consent "that they + * have created"; DELETE is what the AISP does after the PSU has revoked consent with the AISP. + * So for a standard caller there is no PSU in the session at all, and the Consumer the consent was + * lodged under is the whole of the authorisation: that AISP may read and revoke it whatever its + * status, and no other Consumer may. + * + * The user check is kept for a caller that does present a PSU. OBP allows credentials the standard + * does not describe here, and for those the stricter rule applies: a session acting as one PSU + * cannot reach another PSU's consent, and the lodging TPP cannot use a PSU session to do it + * either. That path is a superset of the standard, never a way around it -- the PSU comparison + * only ever narrows, and a caller with no PSU never reaches it. + * + * Blank ids are treated as absent: a consent that was never authorised stores no user id, and one + * lodged before consumer binding existed stores no consumer id. + * + * Kept here rather than inline so the four endpoints that need it (v3.1 and v4.0.1, GET and + * DELETE) share one definition, and so the rule can be tested without standing up a request -- + * same reasoning as validateUKConsentPermissions above. + */ + def checkUKConsentAccess( + consentUserId: String, + consentConsumerId: String, + callerUserId: Option[String], + callerConsumerId: Option[String] + ): Option[String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + (present(consentUserId), callerUserId.flatMap(present)) match { + case (Some(psu), Some(caller)) => + // The consent belongs to a PSU and the caller is acting as one: they must be the same PSU. + if (psu == caller) None else Some(ErrorMessages.ConsentDoesNotMatchUser) + case _ => + // Either the consent has no PSU yet, or the caller is not acting as one. Either way the + // Consumer that lodged it is what identifies a legitimate caller. + val owner = present(consentConsumerId) + if (owner.forall(id => callerConsumerId.flatMap(present).contains(id))) None + else Some(ErrorMessages.ConsentDoesNotMatchConsumer) + } + } + def createUKConsentJWT( user: Option[User], bankId: Option[String], diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala new file mode 100644 index 0000000000..74859b7d46 --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala @@ -0,0 +1,114 @@ +package code.api.UKOpenBanking.v4_0_1 + +import code.api.util.APIUtil.{ResourceDoc, UserOrApplication, buildOperationId} +import code.api.util.Consent +import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +// Who may read or revoke a UK account-access-consent. +// +// The standard names one caller and it is not the PSU: GET and DELETE on account-access-consents +// carry Grant Type "Client Credentials" in both v3.1 and v4.0.1, so a standard caller has no PSU in +// the session and the Consumer the consent was lodged under decides everything. The rule used to be +// four copies of the same inline pair of checks across v3.1/v4.0.1 GET/DELETE, keyed on the caller's +// user id -- which silently assumed a user is always present. +// +// Those PSU-less combinations are covered here explicitly, including the one whose outcome changes: +// an authorised consent reached with no PSU used to be refused with ConsentDoesNotMatchUser and is +// now decided by the Consumer, which is what the standard asks for. +// +// The user check still applies to a caller that does present a PSU -- OBP allows credentials the +// standard does not describe here -- so those combinations are covered too, unchanged. +// +// The rule function is unit-tested rather than driven over HTTP because the test framework signs +// with OAuth1, which always attaches a user -- there is no way to make a genuinely PSU-less request +// from here. The ResourceDoc auth mode is pinned separately, since that is what lets such a request +// reach the handler at all. +class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { + + object UKOpenBankingV401ConsentAccess extends Tag("UKOpenBankingV401ConsentAccess") + + private val psu = "psu-user-id" + private val otherPsu = "someone-else-user-id" + private val tpp = "lodging-consumer-id" + private val otherTpp = "second-consumer-id" + + feature("Consent.checkUKConsentAccess") { + + scenario("the PSU a consent is bound to may use it", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess(psu, tpp, Some(psu), Some(tpp)) should equal(None) + } + + scenario("a different PSU may not use a bound consent", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp)) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("the PSU check wins over the Consumer once a consent is bound", UKOpenBankingV401ConsentAccess) { + // Even the Consumer that lodged it cannot act as another PSU. + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp)) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("an unbound consent may be used by the Consumer that lodged it", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(tpp)) should equal(None) + } + + scenario("an unbound consent may not be used by a second TPP", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + // The client-credentials cases: no PSU in the session at all. + scenario("a PSU-less call may use an unbound consent it lodged", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, None, Some(tpp)) should equal(None) + } + + scenario("a PSU-less call may use a bound consent it lodged", UKOpenBankingV401ConsentAccess) { + // The one combination whose outcome changes, and the reason: this is how the standard has the + // AISP poll and revoke its own consent after the PSU has authorised it. It used to be refused. + Consent.checkUKConsentAccess(psu, tpp, None, Some(tpp)) should equal(None) + } + + scenario("a PSU-less call from a second TPP is still refused", UKOpenBankingV401ConsentAccess) { + // Dropping the user check does not open the consent to everyone: the Consumer still decides. + Consent.checkUKConsentAccess(psu, tpp, None, Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkUKConsentAccess("", tpp, None, Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("a PSU-less call with no Consumer at all is refused", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess(psu, tpp, None, None) should equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("blank ids count as absent, not as a value to match", UKOpenBankingV401ConsentAccess) { + // A consent lodged before consumer binding existed stores no consumer id; nothing identifies a + // wrong caller, so it cannot be refused on that basis. + Consent.checkUKConsentAccess("", "", None, Some(tpp)) should equal(None) + Consent.checkUKConsentAccess(null, null, None, None) should equal(None) + // A blank caller user id is not a PSU either -- it must not accidentally match a blank binding. + Consent.checkUKConsentAccess(psu, tpp, Some(" "), Some(tpp)) should equal(None) + } + } + + feature("consent-by-id ResourceDocs accept a client-credentials caller") { + // Without this the docs default to UserOnly, which sends ResourceDocMiddleware down + // anonymousAccess and 401s any request carrying no user -- so the rule above would never be + // reached. Pinned because nothing else would notice a revert: these endpoints keep working for + // as long as OAuth2 token parsing auto-vivifies a user for a client-credentials token. + for (name <- List("getAccountAccessConsentsConsentId", "deleteAccountAccessConsentsConsentId")) { + scenario(s"v4.0.1 $name declares UserOrApplication", UKOpenBankingV401ConsentAccess) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.ukOpenBankingV401, name))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } + scenario(s"v3.1 $name declares UserOrApplication", UKOpenBankingV401ConsentAccess) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.ukOpenBankingV31, name))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } + } + } +} From b5d556d2016dccc3a75b93f0ee0d21e5697b1843 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 4 Aug 2026 12:36:56 +0200 Subject: [PATCH 51/63] fix: declare the auth mode BG consent authorisation reads actually use (#60) In Berlin Group the PSU never calls the API. Under Redirect it authenticates at the ASPSP's own site; under Embedded it hands its factors to the TPP. Everything on the wire is the TPP acting as itself, and the Implementation Guidelines say so for these two in particular (section 4.6, Authorisation Endpoints): the ASPSP "will give access to these sub-resources to the TPP by returning corresponding hyperlinks", and "the authorisation status would still result by submitting the command GET .../authorisations/authorisationId". Both handlers already reflect that. getConsentAuthorisation and getConsentScaStatus use executeAndRespond and never touch cc.user. Only their ResourceDocs disagreed: with no authMode they take the UserOnly default, which sends ResourceDocMiddleware down anonymousAccess, and that returns 401 for a request carrying no user. The consent endpoints in this same file -- POST /consents, GET and DELETE /consents/CONSENTID, GET /consents/CONSENTID/status -- have been UserOrApplication all along, so this is also a consistency fix within the family. No behaviour changes today. A client-credentials caller still resolves to an auto-vivified user, so the UserOnly path never fires; what this does is keep these two working once that stops. Both docs get a scenario pinning the mode, because nothing else would notice a revert until that day. The POST and PUT siblings on the same paths are deliberately left alone. They are the Embedded SCA steps, they carry PSU credentials in the body, and both open with cc.user.openOrThrowException -- so unlike these two they need an answer to "which PSU is this challenge for" before their auth mode means anything. That is a design question, not a contract one. --- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 13 ++++++++++++ .../AccountInformationServiceAISApiTest.scala | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) 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 61a18e5da4..2a5a0ef61d 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 @@ -1164,6 +1164,17 @@ This function returns an array of hyperlinks to all generated authorisation sub- }""")), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + // The TPP reads its own consent's authorisation sub-resources; no PSU is party to the call. + // The Implementation Guidelines put it plainly (§4.6, Authorisation Endpoints): the ASPSP + // "will give access to these sub-resources to the TPP by returning corresponding hyperlinks", + // and "the authorisation status would still result by submitting the command GET + // .../authorisations/authorisationId". In Berlin Group the PSU never calls the API at all -- + // it authenticates at the ASPSP under Redirect, or hands its factors to the TPP under + // Embedded. The handler already reflects that, taking no user; only the doc disagreed, and a + // doc left on the UserOnly default sends the middleware down anonymousAccess, which 401s a + // request carrying no user. Brings these into line with the consent endpoints in this same + // file, which have been UserOrApplication all along. + authMode = UserOrApplication, http4sPartialFunction = Some(getConsentAuthorisation) ) @@ -1182,6 +1193,8 @@ This method returns the SCA status of a consent initiation's authorisation sub-r }""")), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + // As above -- reading the SCA status is the TPP polling its own authorisation sub-resource. + authMode = UserOrApplication, http4sPartialFunction = Some(getConsentScaStatus) ) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index f70fdb0535..b19db1e85d 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -984,4 +984,25 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit } } + // Reading a consent's authorisation sub-resources is the TPP polling its own consent -- in Berlin + // Group the PSU never calls the API at all, it authenticates at the ASPSP under Redirect or hands + // its factors to the TPP under Embedded. Both handlers already reflect that, taking no user. The + // ResourceDocs did not, and a doc left on the UserOnly default sends the middleware down + // anonymousAccess, which 401s a request carrying no user. + // + // Pinned because nothing else would notice a revert: these keep working for as long as OAuth2 + // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day + // that stops. The consent endpoints in the same file have been UserOrApplication all along, so + // this is also a consistency guard within the family. + feature("BG v1.3 - consent authorisation sub-resources accept a client-credentials caller") { + for (name <- List(nameOf(Http4sBGv13AIS.getConsentAuthorisation), nameOf(Http4sBGv13AIS.getConsentScaStatus))) { + scenario(s"$name declares UserOrApplication", BerlinGroupV1_3) { + val docs = APIUtil.ResourceDoc.getResourceDocs( + List(APIUtil.buildOperationId(ConstantsBG.berlinGroupVersion1, name))) + docs should not be empty + docs.foreach(_.authMode should equal(APIUtil.UserOrApplication)) + } + } + } + } \ No newline at end of file From 7115f5713c0fa671fda3ea1379f70c13622448ce Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 4 Aug 2026 13:36:48 +0200 Subject: [PATCH 52/63] fix(concurrency): make the account application decision one-shot from REQUESTED (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateStatus guarded its conditional UPDATE on the status it had just loaded instead of the fixed starting status. That only caught the interleaving where two callers read the same old value; when the calls serialise, the second one loads what the first wrote, its guard matches, and it overwrites the decision with no error. A REJECTED application could therefore be re-decided as ACCEPTED — and the ACCEPTED branch of the endpoint opens a bank account, so the overwrite is not recoverable. Guard on REQUESTED, matching the sibling transitions in DoobieBusinessStatusQueries (AccountAccessRequest guards on INITIATED, the challenge CAS on successful_c=false). Reword the zero-row failure, which now also covers "a decision was already recorded", and reuse the same constant for the initial status set at creation. This is what made the M3 race scenario intermittently red: it failed only when the two threads happened to serialise with REJECTED landing first. Add M3b, which reproduces the same defect deterministically with two sequential calls. --- .../MappedAccountApplication.scala | 22 +++++---- .../code/concurrency/CONCURRENCY_HAZARDS.md | 3 +- .../ConcurrentBusinessStatusRaceTest.scala | 47 ++++++++++++++++++- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala b/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala index 3cfca0a8e9..4cd8491147 100644 --- a/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala +++ b/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala @@ -14,6 +14,9 @@ import scala.concurrent.Future object MappedAccountApplicationProvider extends AccountApplicationProvider { + /** The status every application starts in, and the only status a decision may be taken from. */ + private val RequestedStatus = "REQUESTED" + override def getAll(): Future[Box[List[AccountApplication]]] = Future { tryo{MappedAccountApplication.findAll()} } @@ -25,7 +28,7 @@ object MappedAccountApplicationProvider extends AccountApplicationProvider { override def createAccountApplication(productCode: ProductCode, userId: Option[String], customerId: Option[String]): Future[Box[AccountApplication]] = Future { tryo { - MappedAccountApplication.create.mCode(productCode.value).mUserId(userId.orNull).mCustomerId(customerId.orNull).mStatus("REQUESTED").saveMe() + MappedAccountApplication.create.mCode(productCode.value).mUserId(userId.orNull).mCustomerId(customerId.orNull).mStatus(RequestedStatus).saveMe() } } @@ -36,15 +39,18 @@ object MappedAccountApplicationProvider extends AccountApplicationProvider { case Full(accountApplication) if(accountApplication.status == "ACCEPTED") => Failure(s"${ErrorMessages.AccountApplicationAlreadyAccepted} Current Account-Application-Id($accountApplicationId)") case Full(accountApplication) => - // Optimistic CAS: transition only if the status hasn't changed since we loaded it. Two - // concurrent updates that both read the same status can no longer both write — the loser - // (0 rows) gets a Failure instead of silently overwriting the winner's decision. + // The decision is one-shot: it may only be taken from REQUESTED. Guarding on the fixed + // initial status rather than the one just loaded is what makes that hold. A guard built + // from the loaded status matches whatever a preceding decision wrote, so a REJECTED + // application could be re-decided as ACCEPTED — and the ACCEPTED branch of the endpoint + // opens a bank account, so that overwrite is not recoverable. val rows = code.bankconnectors.DoobieBusinessStatusQueries.conditionalAccountApplicationStatus( - accountApplication.id.get, accountApplication.status, status) + accountApplication.id.get, RequestedStatus, status) if (rows == 1) MappedAccountApplication.find(By(MappedAccountApplication.mAccountApplicationId, accountApplicationId)) - // Use the generic update-failure code here: the concurrent winner may have written any - // status (ACCEPTED or REJECTED), so the "already accepted" message would be misleading. - else Failure(s"${ErrorMessages.UpdateAccountApplicationStatusError} Status changed concurrently. Current Account-Application-Id($accountApplicationId)") + // 0 rows means the application left REQUESTED — either a concurrent decision won the race + // or one was already recorded. Use the generic update-failure code: the winner may have + // written any status, so the "already accepted" message would be misleading. + else Failure(s"${ErrorMessages.UpdateAccountApplicationStatusError} The account application is no longer in $RequestedStatus status. Current Account-Application-Id($accountApplicationId)") case Empty => Failure(s"${ErrorMessages.AccountApplicationNotFound} Current Account-Application-Id($accountApplicationId)") case _ => Failure(ErrorMessages.UnknownError) } diff --git a/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md b/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md index 89d2384424..7e87bc2f38 100644 --- a/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md +++ b/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md @@ -252,7 +252,8 @@ confirmed before each fix, all green after. | **H6** | ″ | `ObpLookupSystem.obpLookupSystem` unguarded var | `@volatile` + synchronized init (structural reflection test) | | **M9** | ″ | `ObpActorSystem` actor-system vars | `@volatile` + synchronized init (structural reflection test) | | **M2** | `ConcurrentBusinessStatusRaceTest` | `AccountAccessRequest.updateStatus` no terminal guard | conditional `UPDATE … WHERE status='INITIATED'` (`DoobieBusinessStatusQueries`) | -| **M3** | ″ | `MappedAccountApplication.updateStatus` in-memory ACCEPTED guard | optimistic CAS `UPDATE … WHERE mstatus=` | +| **M3** | ″ | `MappedAccountApplication.updateStatus` in-memory ACCEPTED guard | conditional `UPDATE … WHERE mstatus='REQUESTED'` (`DoobieBusinessStatusQueries`) | +| **M3b** | ″ | ″ — the first CAS guarded on the *loaded* status, so a serialised second decision matched its own read and overwrote the first | same fixed-`REQUESTED` guard; deterministic sequential reproduction | | **M4** | ″ | `MappedChallengeProvider.validateChallenge` non-CAS success flip | CAS `UPDATE … SET successful_c=true WHERE challengeid=? AND successful_c=false` | **M1** (`Http4s510.updateTransactionRequestStatus` lacked the row lock that `Http4s400` has) is fixed diff --git a/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala b/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala index 16605c28ba..9090cb1380 100644 --- a/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala +++ b/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala @@ -40,7 +40,15 @@ import scala.concurrent.duration._ * the guard, and both write — the last one wins non-deterministically. A legitimate ACCEPTED * transition can be overwritten by a concurrent REJECTED. * - * EXPECTED TO FAIL (M2, M3) until a conditional UPDATE WHERE status='' is used. + * M3b (testable, deterministic): + * The sequential form of M3, and the reason M3 stayed intermittently red after the conditional + * UPDATE was introduced. That UPDATE guarded on the status the caller had just loaded, so it only + * caught the interleaving where both callers read the same old value. When the two calls serialise, + * the second one loads what the first wrote and its guard matches — the decision is overwritten + * with no error. M3b reproduces that with two ordinary sequential calls, no threads involved. + * + * All four are fixed. M2/M3/M3b/M4 now guard on a fixed starting state + * (INITIATED / REQUESTED / successful_c=false) so each decision can only be taken once. * Tagged ConcurrencyRace. */ class ConcurrentBusinessStatusRaceTest extends ConcurrentRaceSetup { @@ -128,6 +136,43 @@ class ConcurrentBusinessStatusRaceTest extends ConcurrentRaceSetup { } } + scenario("M3b: a REJECTED AccountApplication must not be silently re-decided as ACCEPTED", ConcurrencyRace) { + Given("an AccountApplication in REQUESTED state") + val appId = UUID.randomUUID.toString + MappedAccountApplication.create + .mAccountApplicationId(appId) + .mCode(ProductCode("__conc_m3b_product").value) + .mUserId(resourceUser1.userId) + .mCustomerId(UUID.randomUUID.toString) + .mStatus("REQUESTED") + .saveMe() + + When("it is REJECTED and then a second decision tries to ACCEPT it") + // The deterministic (sequential) form of the M3 race. M3's threads only both write when the + // barrier releases them close enough together to both read REQUESTED; when they serialise, the + // second call reads the status the first one wrote. A guard keyed on that freshly-read status + // matches its own read and lets the overwrite through, so the outcome below is exactly what M3 + // observes intermittently — reproduced here with no threads and no timing dependency. + val rejected = Await.result(MappedAccountApplicationProvider.updateStatus(appId, "REJECTED"), 10.seconds) + val accepted = Await.result(MappedAccountApplicationProvider.updateStatus(appId, "ACCEPTED"), 10.seconds) + + val finalStatus = MappedAccountApplication + .find(By(MappedAccountApplication.mAccountApplicationId, appId)) + .map(_.status).getOrElse("missing") + + Then("only the first decision may take effect — the application stays REJECTED") + withClue( + s"rejected=$rejected accepted=$accepted finalStatus=$finalStatus: " + + s"the decision on an account application is one-shot — it may only be taken from REQUESTED. " + + s"Accepting an already-REJECTED application also creates a bank account, so a silent " + + s"re-decision is not recoverable — " + ) { + rejected shouldBe a[Full[_]] + accepted shouldBe a[Failure] + finalStatus should equal("REJECTED") + } + } + // M1 (Http4s510 updateTransactionRequestStatus lacks the row lock that Http4s400 has) is fixed at // the endpoint: it now calls DoobieTransactionRequestQueries.lockTransactionRequest within the // request transaction. It has no provider-level reproduction here because the FOR UPDATE lock only From d9cb47ea4d61f49c9c2d1e737cddf6ca3f12f410 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 4 Aug 2026 15:20:02 +0200 Subject: [PATCH 53/63] fix: unify consent ownership checks for OBP-native reads and Berlin Group authorisation (#62) * fix: resolve the human behind a consent-authenticated consent read GET /obp/v5.1.0/user/current/consents/CONSENT_ID compared the consent's PSU against CallContext.userId, which returns the authenticated principal. Under Consent-Id / Consent-JWT authentication that principal is the per-consent shadow user, not the person, so the comparison could never match and the PSU was told their own consent did not exist. The subject is now CallContext.humanUser, the accessor the codebase already keeps for this distinction and the one checkUKConsent uses for the same comparison. The rule moves into Consent.checkObpConsentUserAccess so it can be stated once, argued once and tested without standing up a request, following validateUKConsentPermissions and checkUKConsentAccess. A consent with no PSU yet stays readable, deliberately: this endpoint is where a PSU inspects a consent before deciding to authorise it, and the app doing the inspecting belongs to the PSU rather than to the TPP that lodged the consent, so the Consumer fallback the standards use would break the journey instead of tightening it. That is where OBP-native's rule parts company with theirs, and why this is its own function. What it leaves open is recorded in the scaladoc: an unbound consent's metadata is readable by any authenticated caller who knows its consent id. Behaviour change: a request authenticated by a consent can now read that consent, where it previously received OBP-35001. Reads by an unrelated user are unaffected and still 404. * fix: bind a Berlin Group consent only for the TPP that lodged it POST /consents/CONSENTID/authorisations and PUT .../AUTHORISATIONID are the two calls that decide who a consent ends up belonging to: the first mints the SCA challenge, the second answers it and writes the PSU onto the consent row. Neither carried an ownership guard, so any authenticated caller could raise a challenge on any consent id and then answer their own, claiming a consent lodged by a different TPP or re-binding one another PSU had already authorised -- updateConsentUser overwrites mUserId unconditionally. Leaving these consents unowned at lodging time, which the standard wants and 6060f4252 implemented, widened what that reaches. The Consumer half is the standard's own blanket rule, stated once for the whole API in the Implementation Guidelines, section 4.11 API Access Methods: all methods submitted by a TPP addressing dynamically created resources may only apply to resources created by the same TPP before. A consent and its authorisation sub-resources are such resources, and deleteConsent and getConsentInformation in this same file already enforce exactly that; only the authorisation pair was left without it. The PSU half covers re-binding, which the standard leaves to the ASPSP and says so where it defines PSU-ID: the ASPSP might check whether PSU-ID and token match. That lands on the same rule as checkUKConsentAccess by a different route -- UK's rests on its Endpoints table marking these calls Client Credentials, Berlin Group's on the blanket same-TPP rule plus PSU binding happening at SCA time -- so the two now share one private implementation while each keeps its own argument. Consent.genuinePsu extracts the pseudo-user filter 6060f4252 left duplicated inline. It is required here, not tidying: a client-credentials token resolves to an auto-vivified user keyed on the caller's own client key, and comparing that against a consent's real owner would refuse a legitimate TPP poll under the Redirect approach, where the PSU authenticates at the ASPSP rather than through the TPP. Behaviour change: both endpoints now return 403 OBP-35015 when the caller's Consumer did not lodge the consent, and 403 OBP-35023 when a genuine PSU tries to take over a consent already bound to someone else. Both previously succeeded. A TPP that lodges and authorises under one Consumer, which is what the Berlin Group flow describes and what the end-to-end suite exercises, is unaffected. * test: pin what genuinePsu returns when a session carries no PSU Follow-up work on the Berlin Group authorisation handlers builds on this function, and the case it depends on is the one that looks like an absence. Per the standard the caller of those endpoints is the TPP, with the PSU's authentication factors travelling in the request body rather than in the session, so None is the ordinary answer for a conforming call -- not a failure to defend against. Left to a scaladoc, nothing would catch that being narrowed later. Covers the four shapes: no user in the session, only the Consumer's own auto-vivified pseudo-identity, a genuine PSU, and the degenerate case where no Consumer was identified at all. The last one keeps the pseudo-user, since there is no client key to compare against, so it also asserts what checkBerlinGroupConsentAccess then does with it -- refuse on the PSU half when the consent is bound and on the Consumer half when it is not, rather than letting it through. * refactor: share the Berlin Group consent fixtures between both suites The new consent-access suite had its own copy of the consent body, the PSU-less consent builder and the client-credentials session, which the account-information suite already defined. The quality gate caught it as duplicated new code, and it was a maintenance trap besides: the client-credentials fixture encodes a non-obvious fact about how OAuth2 token parsing auto-vivifies a user, and a copy that drifted from it would quietly stop testing the thing it exists for. Moves them into a BerlinGroupConsentFixtures trait that both suites now extend, and drops both copies. No behaviour change -- the fixtures are the account-information suite's originals, moved rather than rewritten. --- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 21 +- .../scala/code/api/util/ConsentUtil.scala | 111 ++++++++ .../scala/code/api/v5_1_0/Http4s510.scala | 9 +- .../AccountInformationServiceAISApiTest.scala | 126 +-------- .../v1_3/BerlinGroupConsentFixtures.scala | 136 +++++++++ .../BerlinGroupV13ConsentAccessTests.scala | 257 ++++++++++++++++++ .../api/v5_1_0/ConsentOwnershipTests.scala | 153 +++++++++++ 7 files changed, 684 insertions(+), 129 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala create mode 100644 obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala create mode 100644 obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala 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 2a5a0ef61d..c30ef7eef9 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 @@ -14,7 +14,7 @@ import code.api.util.CallContext import code.api.util.ApiTag._ import code.api.util.CustomJsonFormats import code.api.util.ErrorMessages._ -import code.api.util.{ApiTag, NewStyle} +import code.api.util.{ApiTag, Consent, NewStyle} import code.api.util.newstyle.ViewNewStyle import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.consent.{ConsentStatus, Consents} @@ -504,6 +504,15 @@ object Http4sBGv13AIS extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, ConsentNotFound, 403) } + // Starting an authorisation is the entry to claiming the consent: the challenge minted + // here is what the PUT twin answers before binding a PSU. Without this, any authenticated + // caller could raise a challenge on any consent id and then answer their own. + _ <- Consent.checkBerlinGroupConsentAccess( + consent.userId, consent.consumerId, + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get)) match { + case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) + case None => Future.successful(true) + } (challenges, callContext) <- NewStyle.function.createChallengesC2( List(u.userId), ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, @@ -546,9 +555,17 @@ object Http4sBGv13AIS extends MdcLoggable { if (checkTransactionAuthorisation(parsedJson)) { for { _ <- passesPsd2Aisp(callContext) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + storedConsent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, ConsentNotFound, 403) } + // updateConsentUser below overwrites mUserId unconditionally, so this is the check that + // decides who a consent ends up belonging to. See Consent.checkBerlinGroupConsentAccess. + _ <- Consent.checkBerlinGroupConsentAccess( + storedConsent.userId, storedConsent.consumerId, + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get)) match { + case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) + case None => Future.successful(true) + } failMsg = s"$InvalidJsonFormat The Json body should be the $TransactionAuthorisation " updateJson <- NewStyle.function.tryons(failMsg, 400, callContext) { parsedJson.extract[TransactionAuthorisation] 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 b5886f1ac1..a0f88fec7e 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1425,6 +1425,64 @@ object Consent extends MdcLoggable { consentConsumerId: String, callerUserId: Option[String], callerConsumerId: Option[String] + ): Option[String] = + psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + + /** + * Decide whether a caller may drive a Berlin Group consent's authorisation sub-resources -- + * starting one, and answering it, the two steps that bind the consent to a PSU -- returning the + * reason to refuse with 403 or None when it is allowed. + * + * The Consumer half is the standard's own blanket rule, not a UK import. The Implementation + * Guidelines state it once for the whole API, in section 4.11 "API Access Methods" (p.24): "all + * methods submitted by a TPP, which are addressing dynamically created resources in this API, may + * only apply to resources which have been created by the same TPP before." A consent and its + * authorisation sub-resources are exactly such dynamically created resources, so the Consumer the + * consent was lodged under is what identifies a legitimate caller. Two endpoints in this family + * already enforce that inline -- deleteConsent and getConsentInformation -- and only the + * authorisation pair was left without it. + * + * Note who that caller is. In Berlin Group the PSU does not call the API: under the Redirect + * approach it authenticates at the ASPSP, and under Embedded it hands its authentication factors + * to the TPP, which relays them. So on this pair the session is the TPP's, and a Consumer check is + * the substantive one -- which is why callerUserId must be a genuine PSU (see genuinePsu) rather + * than whatever principal the token resolved to. + * + * The PSU half covers re-binding. updateConsentUser overwrites mUserId unconditionally, so without + * it a second party could take over a consent another PSU has already authorised. The standard + * leaves PSU identity to the ASPSP to enforce and says so where it defines the PSU-ID header: "the + * ASPSP might check whether PSU-ID and token match, according to ASPSP documentation". Where the + * caller does present a genuine PSU and the consent already names one, they must be the same. + * + * This lands on the same rule as checkUKConsentAccess, and the agreement is worth stating because + * the two derivations are not the same. UK's rests on a per-endpoint claim -- its Endpoints table + * marks these calls Client Credentials, so no PSU is party to them. Berlin Group's rests on the + * blanket same-TPP rule above plus PSU binding happening at SCA time. Different premises, same + * conclusion, so they share one implementation rather than one being copied onto the other. + */ + def checkBerlinGroupConsentAccess( + consentUserId: String, + consentConsumerId: String, + callerUserId: Option[String], + callerConsumerId: Option[String] + ): Option[String] = + psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + + /** + * The rule shared by checkUKConsentAccess and checkBerlinGroupConsentAccess: a consent bound to a + * PSU belongs to that PSU, and otherwise belongs to the Consumer that lodged it. + * + * Blank ids are treated as absent: a consent that was never authorised stores no user id, and one + * lodged before consumer binding existed stores no consumer id. + * + * Private on purpose. Each standard states its own case in its own scaladoc above; this is only + * the mechanism they turned out to share, and it carries no argument of its own. + */ + private def psuOrLodgingTppRefusal( + consentUserId: String, + consentConsumerId: String, + callerUserId: Option[String], + callerConsumerId: Option[String] ): Option[String] = { def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) @@ -1441,6 +1499,59 @@ object Consent extends MdcLoggable { } } + /** + * Decide whether a caller may read an OBP-native consent through GET /user/current/consents/CONSENT_ID, + * returning the reason to refuse or None when it is allowed. + * + * OBP-native answers to no external standard, so the contract is OBP's own API surface, and that + * surface is explicit about the subject: this endpoint is /user/current/..., while its sibling + * /consumer/current/consents/CONSENT_ID is the Consumer-scoped read. Two endpoints, two subjects. + * So the comparison here is against the human the request is on behalf of -- CallContext.humanUser, + * not CallContext.userId, which returns the authenticated principal and under consent + * authentication is the per-consent shadow user rather than the PSU. checkUKConsent already + * resolves the human this way for the same comparison. + * + * A consent with no PSU yet stays readable, and that is deliberate rather than an oversight + * inherited from the previous guard. This endpoint is where the PSU inspects a consent before + * deciding to authorise it, in both the Berlin Group and UK journeys, and the app doing the + * inspecting is the PSU's own -- a different Consumer from the TPP that lodged the consent. Adding + * the Consumer fallback checkUKConsentAccess uses would therefore break the journey, not tighten + * it; that is where OBP-native's rule genuinely parts company with the standards', and why this is + * its own function rather than a second caller of theirs. + * + * What that leaves open, stated plainly: an unbound consent's metadata can be read by any + * authenticated caller who knows its consent id. Claiming one is a separate matter and is gated + * where the binding happens. + * + * Refuses with ConsentNotFound rather than a distinct message, preserving the endpoint's existing + * 404 so it does not tell a stranger that a consent id exists. + */ + def checkObpConsentUserAccess(consentUserId: String, callerHumanUserId: Option[String]): Option[String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + present(consentUserId) match { + case Some(psu) if !callerHumanUserId.flatMap(present).contains(psu) => + Some(ErrorMessages.ConsentNotFound) + case _ => None + } + } + + /** + * The PSU behind a request, where the session really carries one. + * + * A pure client-credentials token still resolves to a user: OAuth2.getOrCreateResourceUser maps + * the JWT sub onto idGivenByProvider, and in that grant the sub is the caller's own client id, so + * an auto-vivified pseudo-user appears where the code expects a person. Ownership checks must not + * mistake it for a PSU -- doing so would refuse a legitimate TPP poll by comparing the TPP's own + * pseudo-identity against the consent's real owner. + * + * Berlin Group consent lodging filters the same way inline (see createConsent); that site was left + * duplicated deliberately while ConsentUtil was being edited on another branch. This is that + * extraction, used by the checks added here. + */ + def genuinePsu(callContext: CallContext): Option[User] = + callContext.user.toOption.filterNot(u => callContext.consumer.map(_.key.get).contains(u.idGivenByProvider)) + def createUKConsentJWT( user: Option[User], bankId: Option[String], diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 163b638831..1609c0ca32 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4626,8 +4626,13 @@ object Http4s510 { for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) - _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, failCode = 404, cc = Some(cc)) { - consent.mUserId == cc.userId || Option(consent.userId).forall(_.isBlank) + // cc.humanUser, not cc.userId: under consent authentication the principal is the + // per-consent shadow user, so comparing it against the consent's PSU never matched and + // the PSU got a 404 for their own consent. See Consent.checkObpConsentUserAccess for + // why an unbound consent stays readable. + _ <- Consent.checkObpConsentUserAccess(consent.userId, cc.humanUser.toOption.map(_.userId)) match { + case Some(reason) => Helper.booleanToFuture(failMsg = reason, failCode = 404, cc = Some(cc))(false) + case None => Future.successful(true) } } yield JSONFactory510.getConsentInfoJson(consent) } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index b19db1e85d..d21600e1a6 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -33,7 +33,7 @@ import java.util.Date import scala.concurrent.Await import scala.concurrent.duration._ -class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 with DefaultUsers { +class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures { object getAccountList extends Tag(nameOf(Http4sBGv13AIS.getAccountList)) @@ -69,11 +69,6 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit object updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod extends Tag("updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod") object updateConsentsPsuDataUpdateAuthorisationConfirmation extends Tag("updateConsentsPsuDataUpdateAuthorisationConfirmation") - def getNextMonthDate(): String = { - val nextMonthDate = LocalDate.now().plusMonths(1) - nextMonthDate.format(DateTimeFormatter.ISO_LOCAL_DATE) - } - feature(s"BG v1.3 - $getAccountList") { scenario("Not Authentication User, test failed ", BerlinGroupV1_3, getAccountList) { val requestGet = (V1_3_BG / "accounts").GET @@ -775,61 +770,6 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit } } - // Builds an unclaimed (PSU-less) Berlin Group consent directly via the provider, mirroring - // how POST /consents builds one for a client_credentials caller (createdByUser = None). - def createUnclaimedBerlinGroupConsent(): ConsentTrait = { - val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) - val acountRoutingIban = accountsRoutingIban.head - val postJsonBody = PostConsentJson( - access = ConsentAccessJson( - accounts = Option(List(ConsentAccessAccountsJson( - iban = Some(acountRoutingIban.accountRouting.address), - bban = None, - pan = None, - maskedPan = None, - msisdn = None, - currency = None, - ))), - balances = None, - transactions = None, - availableAccounts = None, - allPsd2 = None - ), - recurringIndicator = true, - validUntil = getNextMonthDate(), - frequencyPerDay = 4, - combinedServiceIndicator = Some(false) - ) - val validUntilDate = BgSpecValidation.getDate(postJsonBody.validUntil) - - val createdConsent = Consents.consentProvider.vend.createBerlinGroupConsent( - user = None, - consumer = Some(testConsumer), - recurringIndicator = postJsonBody.recurringIndicator, - validUntil = validUntilDate, - frequencyPerDay = postJsonBody.frequencyPerDay, - combinedServiceIndicator = postJsonBody.combinedServiceIndicator.getOrElse(false), - apiStandard = Some(ConstantsBG.berlinGroupVersion1.apiStandard), - apiVersion = Some(ConstantsBG.berlinGroupVersion1.apiShortVersion) - ).openOrThrowException("test consent creation failed") - - val consentJWT = Await.result( - Consent.createBerlinGroupConsentJWT( - None, - postJsonBody, - createdConsent.secret, - createdConsent.consentId, - Some(testConsumer.consumerId.get), - Some(validUntilDate), - None - ), - 10.seconds - ).openOrThrowException("test consent JWT creation failed") - Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT) - - createdConsent - } - feature(s"BG v1.3 - unclaimed consent SCA (regression: GET /obp/v5.1.0/user/current/consents/CONSENT_ID 404 before SCA, wrong authorisationId from ${startConsentAuthorisationTransactionAuthorisation.name})") { scenario("Unclaimed consent: viewable pre-SCA by any user, authorisable, and claimed by the answering PSU on correct OTP", BerlinGroupV1_3, startConsentAuthorisationTransactionAuthorisation, updateConsentsPsuDataTransactionAuthorisation) { setPropsValues("suggested_default_sca_method" -> "DUMMY") @@ -889,70 +829,6 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit } } - // The consent body used by the ownership scenarios below: one account, addressed by the first - // IBAN routing in the test data — the same shape createUnclaimedBerlinGroupConsent() builds. - def bgConsentPostBody(): PostConsentJson = { - val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) - val acountRoutingIban = accountsRoutingIban.head - PostConsentJson( - access = ConsentAccessJson( - accounts = Option(List(ConsentAccessAccountsJson( - iban = Some(acountRoutingIban.accountRouting.address), - bban = None, - pan = None, - maskedPan = None, - msisdn = None, - currency = None, - ))), - balances = None, - transactions = None, - availableAccounts = None, - allPsd2 = None - ), - recurringIndicator = true, - validUntil = getNextMonthDate(), - frequencyPerDay = 4, - combinedServiceIndicator = Some(false) - ) - } - - // A client_credentials token carries the caller's own client id in `sub`, and OAuth2's - // getOrCreateResourceUser turns `sub` into idGivenByProvider — so such a token resolves cc.user to - // an auto-vivified pseudo-user keyed on the consumer's client key rather than leaving it Empty. - // The OAuth1-signed test harness cannot mint that token, so build the same CallContext shape - // directly: a user whose idGivenByProvider IS testConsumer's client key, plus an access token for - // it issued under testConsumer. Signing with this pair gives POST /consents exactly what a - // client_credentials TPP gives it — cc.user.idGivenByProvider == cc.consumer.key. - lazy val pseudoUserOfTestConsumer: ResourceUser = - UserX.findByProviderId(provider = defaultProvider, idGivenByProvider = testConsumer.key.get) - .map(_.asInstanceOf[ResourceUser]) - .getOrElse { - UserX.createResourceUser( - provider = defaultProvider, - providerId = Some(testConsumer.key.get), - createdByConsentId = None, - name = Some(testConsumer.key.get), - email = Some("pseudo.user.of.test.consumer@example.com"), - userId = None, - company = Some("Tesobe GmbH") - ).openOrThrowException("test pseudo user creation failed") - } - - lazy val pseudoUserToken = Tokens.tokens.vend.createToken( - Access, - Some(testConsumer.id.get), - Some(pseudoUserOfTestConsumer.id.get), - Some(randomString(40).toLowerCase), - Some(randomString(40).toLowerCase), - Some(tokenDuration), - Some(TimeSpan(tokenDuration + System.currentTimeMillis())), - Some(new Date(System.currentTimeMillis())), - None - ).openOrThrowException("test pseudo user token creation failed") - - // Same consumer as user1, different token: cc.consumer is testConsumer, cc.user is the pseudo-user. - lazy val clientCredentialsSession = Some(consumer, Token(pseudoUserToken.key.get, pseudoUserToken.secret.get)) - feature(s"BG v1.3 - $createConsent consent ownership") { scenario("A consent lodged on a client-credentials session is left unowned, not bound to the consumer's own pseudo-user", BerlinGroupV1_3, createConsent) { val requestPost = (V1_3_BG / "consents").POST <@ (clientCredentialsSession) 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 new file mode 100644 index 0000000000..67adbf111e --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala @@ -0,0 +1,136 @@ +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ConsentAccessAccountsJson, ConsentAccessJson, PostConsentJson} +import code.api.util.APIUtil.OAuth._ +import code.api.util.Consent +import code.consent.{ConsentTrait, Consents} +import code.model.TokenType.Access +import code.model.UserX +import code.model.dataAccess.{BankAccountRouting, ResourceUser} +import code.setup.DefaultUsers +import code.token.Tokens +import com.openbankproject.commons.model.enums.AccountRoutingScheme +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Fixtures shared by the Berlin Group consent suites: a consent body, a consent lodged without a + * PSU, and a session shaped like a client_credentials caller's. + * + * Extracted here because more than one suite needs them and two copies would have to be kept in + * agreement -- the client-credentials session in particular encodes a non-obvious fact about how + * OAuth2 token parsing behaves, and a copy that drifted from it would quietly stop testing the + * thing it was written for. + */ +trait BerlinGroupConsentFixtures extends BerlinGroupServerSetupV1_3 with DefaultUsers { + + 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 + .findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)).head + PostConsentJson( + access = ConsentAccessJson( + accounts = Option(List(ConsentAccessAccountsJson( + iban = Some(acountRoutingIban.accountRouting.address), + bban = None, + pan = None, + maskedPan = None, + msisdn = None, + currency = None, + ))), + balances = None, + transactions = None, + availableAccounts = None, + allPsd2 = None + ), + recurringIndicator = true, + validUntil = getNextMonthDate(), + frequencyPerDay = 4, + combinedServiceIndicator = Some(false) + ) + } + + /** + * 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() + val validUntilDate = BgSpecValidation.getDate(postJsonBody.validUntil) + + val createdConsent = Consents.consentProvider.vend.createBerlinGroupConsent( + user = None, + consumer = Some(testConsumer), + recurringIndicator = postJsonBody.recurringIndicator, + validUntil = validUntilDate, + frequencyPerDay = postJsonBody.frequencyPerDay, + combinedServiceIndicator = postJsonBody.combinedServiceIndicator.getOrElse(false), + apiStandard = Some(ConstantsBG.berlinGroupVersion1.apiStandard), + apiVersion = Some(ConstantsBG.berlinGroupVersion1.apiShortVersion) + ).openOrThrowException("test consent creation failed") + + val consentJWT = Await.result( + Consent.createBerlinGroupConsentJWT( + None, + postJsonBody, + createdConsent.secret, + createdConsent.consentId, + Some(testConsumer.consumerId.get), + Some(validUntilDate), + None + ), + 10.seconds + ).openOrThrowException("test consent JWT creation failed") + Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT) + + createdConsent + } + + // A client_credentials token carries the caller's own client id in `sub`, and OAuth2's + // getOrCreateResourceUser turns `sub` into idGivenByProvider — so such a token resolves cc.user to + // an auto-vivified pseudo-user keyed on the consumer's client key rather than leaving it Empty. + // The OAuth1-signed test harness cannot mint that token, so build the same CallContext shape + // directly: a user whose idGivenByProvider IS testConsumer's client key, plus an access token for + // it issued under testConsumer. Signing with this pair gives the endpoint exactly what a + // client_credentials TPP gives it — cc.user.idGivenByProvider == cc.consumer.key. + lazy val pseudoUserOfTestConsumer: ResourceUser = + UserX.findByProviderId(provider = defaultProvider, idGivenByProvider = testConsumer.key.get) + .map(_.asInstanceOf[ResourceUser]) + .getOrElse { + UserX.createResourceUser( + provider = defaultProvider, + providerId = Some(testConsumer.key.get), + createdByConsentId = None, + name = Some(testConsumer.key.get), + email = Some("pseudo.user.of.test.consumer@example.com"), + userId = None, + company = Some("Tesobe GmbH") + ).openOrThrowException("test pseudo user creation failed") + } + + lazy val pseudoUserToken = Tokens.tokens.vend.createToken( + Access, + Some(testConsumer.id.get), + Some(pseudoUserOfTestConsumer.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new Date(System.currentTimeMillis())), + None + ).openOrThrowException("test pseudo user token creation failed") + + // Same consumer as user1, different token: cc.consumer is testConsumer, cc.user is the pseudo-user. + lazy val clientCredentialsSession = Some(consumer, Token(pseudoUserToken.key.get, pseudoUserToken.secret.get)) +} diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala new file mode 100644 index 0000000000..1812421fa3 --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala @@ -0,0 +1,257 @@ +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.berlin.group.v1_3.model.ScaStatusResponse +import code.api.util.APIUtil.OAuth._ +import code.api.util.{CallContext, Consent} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} +import code.consent.{ConsentStatus, Consents} +import code.model.TokenType.Access +import code.token.Tokens +import net.liftweb.common.{Empty, Full} +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan +import org.scalatest.Tag + +import java.util.Date + +/** + * Who may drive a Berlin Group consent's authorisation sub-resources. + * + * Two endpoints decide who a consent ends up belonging to: POST /consents/CONSENTID/authorisations + * mints the SCA challenge, and PUT .../AUTHORISATIONID answers it and writes the PSU onto the + * consent row. Neither had any ownership guard, so any authenticated caller could raise a challenge + * on any consent id and then answer their own -- claiming a consent lodged by a different TPP, or + * re-binding one another PSU had already authorised, since updateConsentUser overwrites mUserId + * unconditionally. + * + * The rule and its derivation from the standard live in Consent.checkBerlinGroupConsentAccess. This + * suite pins both halves of it, and pins the two ways it must NOT bite: the lodging TPP's own PSU + * completing SCA, and the lodging TPP polling on a client-credentials session, where cc.user is an + * auto-vivified pseudo-user rather than a person. + */ +class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { + + object BerlinGroupV13ConsentAccess extends Tag("BerlinGroupV13ConsentAccess") + + private val psu = "psu-user-id" + private val otherPsu = "someone-else-user-id" + private val tpp = "lodging-consumer-id" + private val otherTpp = "second-consumer-id" + + // The rule is unit-tested as well as driven over HTTP because the interesting caller shapes -- a + // session with no PSU at all -- cannot be produced by an OAuth1-signed test request, which always + // attaches a user. Same reasoning as UKOpenBankingV401ConsentAccessTests. + feature("Consent.checkBerlinGroupConsentAccess") { + + scenario("the TPP that lodged an unowned consent may authorise it", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(tpp)) should equal(None) + } + + scenario("a second TPP may not authorise a consent it did not lodge", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("a PSU-less call may drive a consent its own Consumer lodged", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(tpp)) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(tpp)) should equal(None) + } + + scenario("a PSU-less call from a second TPP is still refused", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("a PSU-less call with no Consumer at all is refused", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, None) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("the PSU a consent is already bound to may re-authorise it", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(psu), Some(tpp)) should equal(None) + } + + scenario("a different PSU may not re-bind a consent that is already owned", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(tpp)) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("the PSU check wins over the Consumer once a consent is bound", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp)) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("blank ids count as absent, not as a value to match", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(null, null, None, None) should equal(None) + Consent.checkBerlinGroupConsentAccess(" ", tpp, Some(psu), Some(tpp)) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(" "), Some(tpp)) should equal(None) + } + } + + // Pinned rather than left to the scaladoc because the Berlin Group authorisation handlers are + // being reworked on top of this, and the case that matters there is the one that looks like an + // absence: per the standard the caller of these endpoints is the TPP, with the PSU's factors + // travelling in the body rather than in the session, so None is the normal answer for a + // conforming call -- not a failure to defend against. checkBerlinGroupConsentAccess is written + // for that: a caller with no PSU skips the PSU comparison and is judged on its Consumer alone. + feature("Consent.genuinePsu") { + + scenario("a session with no user at all has no PSU", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu(CallContext(user = Empty, consumer = Full(testConsumer))) should equal(None) + } + + scenario("the Consumer's own pseudo-identity is not a PSU", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu( + CallContext(user = Full(pseudoUserOfTestConsumer), consumer = Full(testConsumer))) should equal(None) + } + + scenario("a real person authenticated in the session is a PSU", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu(CallContext(user = Full(resourceUser1), consumer = Full(testConsumer))) + .map(_.userId) should equal(Some(resourceUser1.userId)) + } + + // Degenerate, and it fails closed rather than open: with no Consumer identified there is no key + // to compare against, so the pseudo-user survives the filter -- but callerConsumerId is None + // too, so a bound consent is refused on the PSU half and an unbound one on the Consumer half. + scenario("with no Consumer on the call there is no key to filter against", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu(CallContext(user = Full(pseudoUserOfTestConsumer), consumer = Empty)) + .map(_.userId) should equal(Some(pseudoUserOfTestConsumer.userId)) + + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(pseudoUserOfTestConsumer.userId), None) should + equal(Some(ConsentDoesNotMatchUser)) + Consent.checkBerlinGroupConsentAccess("", tpp, Some(pseudoUserOfTestConsumer.userId), None) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + } + + // resourceUser2 under testConsumer: a genuine second PSU of the *lodging* TPP, which is the only + // way to reach the PSU half of the rule over HTTP -- user2 in the shared fixtures carries + // testConsumer2, so it would be refused on the Consumer half first. + private lazy val secondPsuOfTestConsumerToken = Tokens.tokens.vend.createToken( + Access, + Some(testConsumer.id.get), + Some(resourceUser2.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new Date(System.currentTimeMillis())), + None + ).openOrThrowException("test second PSU token creation failed") + + private lazy val secondPsuOfTestConsumerSession = + Some(consumer, Token(secondPsuOfTestConsumerToken.key.get, secondPsuOfTestConsumerToken.secret.get)) + + private def startAuthorisation(consentId: String, session: Option[(Consumer, Token)]) = + makePostRequest( + (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (session), + """{"scaAuthenticationData":""}""") + + private def answerAuthorisation(consentId: String, authorisationId: String, session: Option[(Consumer, Token)], otp: String) = + makePutRequest( + (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (session), + s"""{"scaAuthenticationData":"$otp"}""") + + feature("BG v1.3 - a consent's authorisation sub-resources answer only to the TPP that lodged it") { + + scenario("A second TPP cannot start an authorisation on a consent it did not lodge", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("user2 carries testConsumer2, which did not lodge this consent") + val response = startAuthorisation(consentId, user2) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchConsumer) + + Then("The consent is untouched: no PSU was bound and no challenge was minted for the caller") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + Option(consent.userId).forall(_.isBlank) should be (true) + consent.status should be (ConsentStatus.received.toString) + } + + scenario("A second TPP cannot answer an authorisation the lodging TPP started", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("Answering it from a second TPP's session is refused before the OTP is even checked") + val response = answerAuthorisation(consentId, authorisationId, user2, "123") + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchConsumer) + + Then("The consent stays unclaimed") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + Option(consent.userId).forall(_.isBlank) should be (true) + } + + scenario("A second PSU of the lodging TPP cannot re-bind a consent another PSU authorised", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("resourceUser1 authorises it") + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + answerAuthorisation(consentId, authorisationId, user1, "123").code should equal(200) + Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId should be (resourceUser1.userId) + + Then("resourceUser2, on the same Consumer, is refused on the PSU half of the rule") + val response = startAuthorisation(consentId, secondPsuOfTestConsumerSession) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchUser) + + Then("The consent still belongs to the PSU that authorised it") + Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId should be (resourceUser1.userId) + } + } + + feature("BG v1.3 - the guard does not bite the flows Berlin Group actually describes") { + + scenario("The lodging TPP's PSU completes SCA and the consent is bound to them", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + val answered = answerAuthorisation(consentId, authorisationId, user1, "123") + answered.code should equal(200) + answered.body.extract[ScaStatusResponse].scaStatus should be ("valid") + + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + consent.userId should be (resourceUser1.userId) + consent.status should be (ConsentStatus.valid.toString) + } + + // The discriminating case for Consent.genuinePsu. A client_credentials token resolves cc.user to + // a pseudo-user keyed on the caller's own client key, not to a person. Compare that against the + // consent's real owner and a legitimate TPP poll on its own bound consent turns into a 403 -- + // which is exactly what Berlin Group's Redirect approach does, the PSU having authenticated at + // the ASPSP rather than through the TPP. + scenario("The lodging TPP may still drive a bound consent on a client-credentials session", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + answerAuthorisation(consentId, authorisationId, user1, "123").code should equal(200) + + Then("A client-credentials session of the same Consumer is not mistaken for a foreign PSU") + val response = startAuthorisation(consentId, clientCredentialsSession) + response.code should equal(201) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala new file mode 100644 index 0000000000..47386f389d --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala @@ -0,0 +1,153 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2019, 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 +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + */ +package code.api.v5_1_0 + +import org.json4s._ +import code.api.{Constant, RequestHeader} +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.Consent +import code.api.util.ErrorMessages._ +import code.api.v3_1_0.{ConsentJsonV310, PostConsentChallengeJsonV310, PostConsentEntitlementJsonV310, PostConsentViewJsonV310} +import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 +import code.entitlement.Entitlement +import code.setup.PropsReset +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +/** + * Who the OBP-native consent read answers to. + * + * GET /obp/v5.1.0/user/current/consents/CONSENT_ID compared the consent's PSU against + * CallContext.userId -- the authenticated principal. Under Consent-Id / Consent-JWT authentication + * that principal is the per-consent shadow user, never the human, so the comparison could not match + * and the PSU was told their own consent did not exist. The subject is now CallContext.humanUser, + * the accessor the codebase already keeps for exactly this distinction. + * + * The rule, and why a consent with no PSU yet stays readable by anyone, is in + * Consent.checkObpConsentUserAccess. + */ +class ConsentOwnershipTests extends V510ServerSetup with PropsReset { + + object VersionOfApi extends Tag(ApiVersion.v5_1_0.toString) + object CreateConsent extends Tag(nameOf(Implementations5_1_0.createConsent)) + object ConsentOwnership extends Tag("ConsentOwnership") + + private val psu = "psu-user-id" + private val otherPsu = "someone-else-user-id" + + feature("Consent.checkObpConsentUserAccess") { + + scenario("the PSU a consent is bound to may read it", ConsentOwnership) { + Consent.checkObpConsentUserAccess(psu, Some(psu)) should equal(None) + } + + scenario("a different human may not read a bound consent", ConsentOwnership) { + Consent.checkObpConsentUserAccess(psu, Some(otherPsu)) should equal(Some(ConsentNotFound)) + } + + scenario("a caller with no human at all may not read a bound consent", ConsentOwnership) { + Consent.checkObpConsentUserAccess(psu, None) should equal(Some(ConsentNotFound)) + } + + // Deliberate, and load-bearing: this endpoint is where a PSU inspects a consent before deciding + // to authorise it, and the app doing the inspecting belongs to the PSU, not to the TPP that + // lodged the consent. See the Berlin Group SCA regression in AccountInformationServiceAISApiTest. + scenario("a consent with no PSU yet is readable", ConsentOwnership) { + Consent.checkObpConsentUserAccess("", Some(psu)) should equal(None) + Consent.checkObpConsentUserAccess(null, None) should equal(None) + Consent.checkObpConsentUserAccess(" ", Some(otherPsu)) should equal(None) + } + } + + private val validHeaderConsumerKey = + List((RequestHeader.`Consumer-Key`, user1.map(_._1.key).getOrElse("SHOULD_NOT_HAPPEN"))) + + private lazy val bankId = randomBankId + private lazy val bankAccount = randomPrivateAccount(bankId) + private lazy val entitlements = List(PostConsentEntitlementJsonV310("", CanGetAnyUser.toString())) + private lazy val views = List(PostConsentViewJsonV310(bankId, bankAccount.id, Constant.SYSTEM_OWNER_VIEW_ID)) + private lazy val postConsentImplicitJsonV310 = SwaggerDefinitionsJSON.postConsentImplicitJsonV310 + .copy(entitlements = entitlements) + .copy(consumer_id = Some(testConsumer.consumerId.get)) + .copy(views = views) + + // Lodge an OBP-native consent for resourceUser1 and take it through SCA, so it ends up ACCEPTED + // and usable as a credential. Returns (consentId, consentJWT). + private def acceptedConsentOfUser1(): (String, String) = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + val request = (v5_1_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1) + val response = makePostRequest(request, write(postConsentImplicitJsonV310), validHeaderConsumerKey) + response.code should equal(201) + val consentId = response.body.extract[ConsentJsonV310].consent_id + val jwt = response.body.extract[ConsentJsonV310].jwt + + val challengeRequest = (v5_1_0_Request / "banks" / bankId / "consents" / consentId / "challenge").POST <@ (user1) + val challengeResponse = makePostRequest( + challengeRequest, write(PostConsentChallengeJsonV310(answer = Consent.challengeAnswerAtTestEnvironment))) + challengeResponse.code should equal(201) + + (consentId, jwt) + } + + feature("Consent-authenticated reads of GET /user/current/consents/CONSENT_ID") { + + scenario("The PSU may read their own consent when the consent itself is the credential", CreateConsent, VersionOfApi, ConsentOwnership) { + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE") + val (consentId, jwt) = acceptedConsentOfUser1() + + When("The consent is presented as the credential, so cc.user is its shadow user") + val request = (v5_1_0_Request / "user" / "current" / "consents" / consentId).GET + val response = makeGetRequest( + request, List((RequestHeader.`Consent-JWT`, jwt)) ::: validHeaderConsumerKey) + + Then("The read resolves the human behind the consent and succeeds -- this used to be a 404") + response.code should equal(200) + (response.body \ "consent_id").extract[String] should equal(consentId) + + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_CERTIFICATE") + } + + scenario("Another user still cannot read a consent bound to someone else", CreateConsent, VersionOfApi, ConsentOwnership) { + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE") + val (consentId, _) = acceptedConsentOfUser1() + + When("user2 asks for a consent that belongs to resourceUser1") + val response = makeGetRequest((v5_1_0_Request / "user" / "current" / "consents" / consentId).GET <@ (user2)) + + Then("It is refused as not found, so the id is not confirmed to a stranger") + response.code should equal(404) + response.body.extract[ErrorMessage].message should include(ConsentNotFound) + + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_CERTIFICATE") + } + } +} From 2f43f2e9a53a2adbf0c768fa418a53b09c643f6c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 5 Aug 2026 11:47:37 +0200 Subject: [PATCH 54/63] fix: mint a Berlin Group consent challenge for the PSU, not for the caller (#65) POST /consents/CONSENTID/authorisations minted its SCA challenge against cc.user, and the PUT twin bound the consent to that same principal. In Berlin Group that principal is the wrong one. The TPP makes these calls, not the PSU: under Redirect the PSU authenticates at the ASPSP, under Embedded it hands its factors to the TPP, which relays them -- the Implementation Guidelines show it as a TPP request carrying the customer's OTP (V1.3.12, section 6.1.1.4, p.123). What that cost is concrete rather than formal. createChallengeInternal delivers the challenge answer to getEmailsByUserId / getPhoneNumbersByUserId of the user the challenge names, and a client-credentials token resolves to the caller's own auto-vivified pseudo-user. So the OTP was mailed to the TPP and never reached the PSU, and the PUT then wrote that pseudo-user onto the consent, updateConsentUser overwriting mUserId unconditionally. The regression test catches it directly, asserting the challenge's expectedUserId rather than only the consent it produces. Where the standard puts the PSU's identity is the PSU-ID header, which OBP had never read. It is not in the body: psuData carries four password fields and no identifier at all, so an Embedded call cannot name its PSU any other way. Consent.resolveBerlinGroupPsu takes the header, the consent's own PSU and a genuine PSU in the session, and answers in the order the standard's conditionality implies -- PSU-ID is asked for when the ASPSP does not already know (sections 7.1 p.195 and 7.2.1 p.206), so what it already knows wins: 1. the consent's PSU, once SCA has bound one; 2. a genuine PSU in the session, which is the Redirect approach; 3. the PSU-ID header, which is Embedded. None of the three is refused with the code the standard defines for exactly that, PSU_CREDENTIALS_INVALID. A header contradicting 1 or 2 is refused rather than resolved by precedence, which the standard sanctions where it defines the header -- "the ASPSP might check whether PSU-ID and token match" (section 6.3.1, p.134) -- and what it closes is specific: otherwise a lodging TPP could name a third party on a bound consent and have that person's OTP mailed to itself. The PUT no longer needs a session user at all. The challenge already records whose authorisation it is, and getChallenge was being called and discarded one line above, so the consent now binds to the challenge's PSU. That also closes the reverse hole: a client-credentials PUT could previously take a consent off its PSU and onto the caller. Two consequences of reading ownership off the challenge. Its consentId is now checked against the path, because the connector's validateChallengeAnswerC4 matches on challengeId alone and ignores the consentId it is handed -- without it, a challenge minted on one consent could be answered on another's and bind the first consent's PSU to the second. And the OTP is validated as the challenge's PSU rather than as the token's principal, since under Embedded the TPP is only relaying it; the caller's own right to be there was already settled by checkBerlinGroupConsentAccess. Passing a derived CallContext keeps this on the Connector path, so CBS-backed deployments are unaffected. PSU-ID resolves against the local identity provider first, then across providers when exactly one user answers to the username, so a federated PSU still resolves and an ambiguous one is refused rather than guessed. What this deliberately does not do is verify a first factor. psuData.password is still unchecked and the updatePsuAuthentication branch stays mocked, so PSU-ID is an assertion by the TPP. It is the OTP, delivered out of band to the PSU this resolves to, that binds the consent -- which is why resolving it correctly is what makes the unverified assertion safe. All seven ResourceDocs now declare UserOrApplication. b5d556d20 brought the two GET siblings across and held these back on the grounds that a doc's auth mode says nothing until the handler has an answer to which PSU an authorisation is for. It now has one, and it does not come from the session. Behaviour changes. The OTP goes to the PSU rather than to a client-credentials caller. POST authorisations returns 401 PSU_CREDENTIALS_INVALID where an unclaimed consent has no PSU in the session and no PSU-ID header, having previously minted a challenge for the caller. The consent binds to the challenge's PSU rather than to the session principal. A challenge answered on a different consent's path is refused with 400. A TPP that authorises with the PSU's own token, which is the Redirect journey the end-to-end suite exercises, is unaffected. --- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 75 +++++- .../scala/code/api/constant/constant.scala | 6 + .../code/api/util/BerlinGroupError.scala | 5 + .../scala/code/api/util/ConsentUtil.scala | 79 ++++++ .../scala/code/api/util/ErrorMessages.scala | 1 + .../src/main/scala/code/users/LiftUsers.scala | 4 + obp-api/src/main/scala/code/users/Users.scala | 8 +- .../BerlinGroupV13ConsentAccessTests.scala | 225 +++++++++++++++++- 8 files changed, 388 insertions(+), 15 deletions(-) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala index c30ef7eef9..f3d1084873 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 @@ -9,6 +9,8 @@ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ import code.api.berlin.group.v1_3.model._ import code.api.berlin.group.v1_3.{BgSpecValidation, JSONFactory_BERLIN_GROUP_1_3, JvalueCaseClass} +import code.api.RequestHeader +import code.api.util.APIUtil import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, createQueriesByHttpParams, fullBoxOrException, getHttpRequestUrlParam, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, unboxFull, unboxFullOrFail} import code.api.util.CallContext import code.api.util.ApiTag._ @@ -490,13 +492,30 @@ 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) + } + } + // ── POST /consents/CONSENTID/authorisations (3 body-guard variants) ───── lazy val startConsentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `bgV13Prefix` / "consents" / consentId / "authorisations" => EndpointHelpers.executeFutureCreated(req) { val cc = req.callContext val callContext = Some(cc) - val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) if (checkTransactionAuthorisation(parsedJson)) { for { @@ -513,8 +532,22 @@ object Http4sBGv13AIS extends MdcLoggable { case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) case None => Future.successful(true) } + headerPsuUserId <- resolvePsuIdHeader(cc, callContext) + // Whose challenge this is, which is also where the OTP gets sent. Not the session's + // principal: in Berlin Group the caller is the TPP. See Consent.resolveBerlinGroupPsu. + psuUserId <- Consent.resolveBerlinGroupPsu( + consent.userId, Consent.genuinePsu(cc).map(_.userId), headerPsuUserId) match { + case Right(userId) => Future.successful(userId) + // A PSU-ID contradicting what the ASPSP already knows is an ownership refusal (403, + // like the guard above); no PSU identifiable at all is the standard's own + // PSU_CREDENTIALS_INVALID (401). booleanToFuture(false) always fails, so the mapped + // value is never reached -- it only lines the two branches up. + case Left(reason) => + val failCode = if (reason == ConsentDoesNotMatchUser) 403 else 401 + booleanToFuture(failMsg = reason, failCode = failCode, cc = callContext)(false).map(_ => "") + } (challenges, callContext) <- NewStyle.function.createChallengesC2( - List(u.userId), + List(psuUserId), ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, None, getSuggestedDefaultScaMethod(), @@ -550,7 +583,6 @@ object Http4sBGv13AIS extends MdcLoggable { case req @ PUT -> `bgV13Prefix` / "consents" / consentId / "authorisations" / authorisationId => EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) - val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) if (checkTransactionAuthorisation(parsedJson)) { for { @@ -570,15 +602,29 @@ object Http4sBGv13AIS extends MdcLoggable { updateJson <- NewStyle.function.tryons(failMsg, 400, callContext) { parsedJson.extract[TransactionAuthorisation] } - (_, callContext) <- NewStyle.function.getChallenge(authorisationId, callContext) - (challenge, callContext) <- NewStyle.function.validateChallengeAnswerC4( + (startedChallenge, callContext) <- NewStyle.function.getChallenge(authorisationId, callContext) + // The connector's validateChallengeAnswerC4 matches on challengeId alone and ignores the + // consentId it is handed, so without this a challenge minted on one consent could be + // answered on another's path -- and the ownership decision below reads off that + // challenge, so it has to be this consent's. + _ <- booleanToFuture( + failMsg = s"$InvalidChallengeChallengeId Current challengeId($authorisationId) does not belong to CONSENTID($consentId) ", + failCode = 400, cc = callContext)(startedChallenge.consentId.contains(consentId)) + // Who this consent binds to. The POST twin minted the challenge for a particular PSU and + // the OTP was delivered to that person, so the challenge is the record of whose + // authorisation this is -- the session is the TPP's and cannot say. + (psu, callContext) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) + // Berlin Group Embedded has the TPP relay the PSU's OTP, so the identity the answer is + // validated against is the challenge's PSU rather than the principal on the token. The + // caller's own right to be here was settled by checkBerlinGroupConsentAccess above. + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, None, Some(consentId), authorisationId, updateJson.scaAuthenticationData, SuppliedAnswerType.PLAIN_TEXT_VALUE, - callContext + callContext.map(_.copy(user = Full(psu))) ) consent <- challenge.scaStatus match { case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => @@ -592,13 +638,13 @@ object Http4sBGv13AIS extends MdcLoggable { consent.toList.size == 1 } _ <- Future { - val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) + 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, u)) map { + _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, psu)) map { unboxFullOrFail(_, callContext, ConsentUserCannotBeAdded) } } yield { @@ -831,6 +877,13 @@ using the extended forms as indicated above. startConsentAuthorisationResponse, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + // Berlin Group's Embedded SCA step, and the TPP is the caller: the PSU either authenticated + // at the ASPSP under Redirect or handed its factors to the TPP under Embedded. Which PSU the + // challenge is for no longer comes from the session -- see Consent.resolveBerlinGroupPsu -- + // so the doc can now say what the call actually is, as its GET siblings already do. Left on + // the UserOnly default it would 401 a client-credentials caller the day OAuth2 token parsing + // stops auto-vivifying a user. + authMode = UserOrApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -845,6 +898,7 @@ using the extended forms as indicated above. startConsentAuthorisationResponse, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -859,6 +913,7 @@ using the extended forms as indicated above. startConsentAuthorisationResponse, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -905,6 +960,7 @@ Maybe in a later version the access path will change. ), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -924,6 +980,7 @@ Maybe in a later version the access path will change. | }""".stripMargin)), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -951,6 +1008,7 @@ Maybe in a later version the access path will change. | }""".stripMargin)), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -970,6 +1028,7 @@ Maybe in a later version the access path will change. | }""".stripMargin)), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) } diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index eddc6ecb04..6cf2b82a50 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -931,6 +931,12 @@ object RequestHeader { final lazy val `PSD2-CERT` = "PSD2-CERT" final lazy val `If-None-Match` = "If-None-Match" + // "Client ID of the PSU in the ASPSP client interface" -- the only place Berlin Group carries the + // PSU's identity, psuData being passwords only. Conditional rather than mandatory: the standard + // asks for it when the ASPSP does not already know who the PSU is (Implementation Guidelines + // V1.3.12, sections 7.1 p.195 and 7.2.1 p.206), which is why it stays out of BerlinGroupCheck's + // mandatory header list. + final lazy val `PSU-ID` = "PSU-ID" // Berlin Group final lazy val `PSU-Geo-Location` = "PSU-Geo-Location" // Berlin Group final lazy val `PSU-Device-Name` = "PSU-Device-Name" // Berlin Group final lazy val `PSU-Device-ID` = "PSU-Device-ID" // Berlin Group 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 c3de4f1fc1..d8b015e8a7 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -57,6 +57,11 @@ object BerlinGroupError { case "401" if message.contains("OBP-20203") => "PSU_CREDENTIALS_INVALID" case "401" if message.contains("OBP-20206") => "PSU_CREDENTIALS_INVALID" case "401" if message.contains("OBP-20207") => "PSU_CREDENTIALS_INVALID" + // The table above defines this code as "PSU-ID cannot be found by ASPSP", which is both of + // these: a PSU-ID naming nobody (OBP-20027), and an authorisation with no PSU identifiable at + // all (OBP-35039). + case "401" if message.contains("OBP-20027") => "PSU_CREDENTIALS_INVALID" + case "401" if message.contains("OBP-35039") => "PSU_CREDENTIALS_INVALID" case "401" if message.contains("OBP-20204") => "TOKEN_EXPIRED" case "401" if message.contains("OBP-20215") => "TOKEN_INVALID" 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 a0f88fec7e..b616f0fc19 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1552,6 +1552,85 @@ object Consent extends MdcLoggable { def genuinePsu(callContext: CallContext): Option[User] = callContext.user.toOption.filterNot(u => callContext.consumer.map(_.key.get).contains(u.idGivenByProvider)) + /** + * Resolve the PSU a Berlin Group consent authorisation is for, returning that PSU's user id or the + * reason to refuse. + * + * The session cannot answer this, and the reason is not a formality. Berlin Group has the TPP make + * these calls, not the PSU: under Redirect the PSU authenticates at the ASPSP, under Embedded it + * hands its factors to the TPP, which relays them (Implementation Guidelines V1.3.12, section + * 6.1.1.4, p.123: "the TPP is transmitting the authentication data of the customer, e.g. an OTP"). + * So on an Embedded call the token is the TPP's, and a client-credentials token still resolves to + * a user -- the caller's own auto-vivified pseudo-identity, see genuinePsu. Minting the challenge + * against that principal names the TPP, and the challenge answer is delivered to whoever the + * challenge names: createChallengeInternal sends it to getEmailsByUserId / getPhoneNumbersByUserId + * of the minted user. The OTP would go to the TPP and never reach the PSU. + * + * Where the standard does put the PSU's identity is the PSU-ID header. It is not in the body: + * psuData carries password, encryptedPassword, additionalPassword and additionalEncryptedPassword, + * and no identifier at all. On the start-authorisation call PSU-ID "shall be transmitted if this + * Request is indicated by 'startAuthorisationWithPsuIdentification' or + * 'startAuthorisationWithPsuAuthentication' ... and this field has not yet been transmitted + * before" (section 7.1, p.195); on the update call it is "contained if not yet contained in a + * pre-ceeding request" (section 7.2.1, p.206). Both make it conditional on the ASPSP not already + * knowing, which is exactly the order used here: + * + * 1. the consent's own PSU, once SCA has bound one -- the strongest form of "already transmitted"; + * 2. a genuine PSU in the session, which is the Redirect approach, where the PSU really is the + * caller; + * 3. the PSU-ID header, which is Embedded, the TPP naming the PSU on the PSU's behalf. + * + * With none of the three there is no one to mint the challenge for and nowhere to send the OTP, so + * the call is refused. That is an ordinary outcome rather than an attack being repelled: a + * conforming client-credentials call that omitted the header lands here. + * + * A header that disagrees with 1 or 2 is refused rather than resolved by precedence. The standard + * sanctions the check where it defines the header -- "the ASPSP might check whether PSU-ID and + * token match, according to ASPSP documentation" (section 6.3.1, p.134) -- and what it closes is + * specific: without it the lodging TPP could name a third party on a consent already bound to + * someone else, and have that person's OTP mailed to them. + * + * What this deliberately does not do is verify a first factor. psuData.password is still not + * checked anywhere in the Berlin Group path, so PSU-ID is an assertion by the TPP and not proof of + * anything. It is the OTP, delivered out of band to the PSU this resolves to, that actually binds + * the consent -- which is why resolving it correctly is what makes the unverified assertion safe, + * and why getting it wrong was the defect rather than a tidiness problem. + */ + def resolveBerlinGroupPsu( + consentUserId: String, + sessionPsuId: Option[String], + headerPsuId: Option[String] + ): Either[String, String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + val alreadyKnown = present(consentUserId).orElse(sessionPsuId.flatMap(present)) + + (alreadyKnown, headerPsuId.flatMap(present)) match { + case (Some(known), Some(named)) if known != named => Left(ErrorMessages.ConsentDoesNotMatchUser) + case (Some(known), _) => Right(known) + case (None, Some(named)) => Right(named) + case (None, None) => Left(ErrorMessages.BerlinGroupPsuNotIdentified) + } + } + + /** + * Resolve a Berlin Group PSU-ID header value -- "Client ID of the PSU in the ASPSP client + * interface" -- to the user it names. + * + * Local users first, since that is what the header means at an ASPSP running its own identity + * store. A federated PSU carries its issuer as provider rather than the local one, so a username + * that is not local is looked up across providers and accepted only when exactly one user answers + * to it. Two would make the header ambiguous, and choosing between them is not the ASPSP's to do + * on the PSU's behalf. + */ + def findPsuByPsuId(psuId: String): Box[User] = + Users.users.vend.getUserByProviderAndUsername(Constant.localIdentityProvider, psuId) or { + Users.users.vend.getUsersByUsername(psuId) match { + case theOnlyOne :: Nil => Full(theOnlyOne) + case _ => Empty + } + } + 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 37750668f3..4c9497691e 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -774,6 +774,7 @@ object ErrorMessages { val ConsentDoesNotMatchStandard = "OBP-35036: The Consent was created by a different API standard than the endpoint using it. A consent may only be used by endpoints of the standard that created it. " val ConsentAccountNotHeldByUser = "OBP-35037: One or more of the specified account_ids is not held by the current user. A consent may only be authorised for accounts the authorising user holds. " 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. " //Authorisations val AuthorisationNotFound = "OBP-36001: Authorisation not found. Please specify valid values for PAYMENT_ID and AUTHORISATION_ID. " diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index 39113489ef..1a5cd589f3 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -111,6 +111,10 @@ object LiftUsers extends Users with MdcLoggable{ } } + override def getUsersByUsername(userName: String): List[User] = { + ResourceUser.findAll(By(ResourceUser.name_, userName)) + } + override def getUserByEmail(email: String): Box[List[ResourceUser]] = { Full(ResourceUser.findAll(By(ResourceUser.email, email))) } diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index 27a300a5fc..786c4bc970 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -42,10 +42,16 @@ trait Users { def getUserByUserIdFuture(userId : String) : Future[Box[User]] def getUsersByUserIdsFuture(userIds : List[String]) : Future[List[User]] - // find ResourceUser by Resourceuser username + // find ResourceUser by Resourceuser username def getUserByProviderAndUsername(provider: String, userName: String) : Box[User] def getUserByProviderAndUsernameFuture(provider: String, username: String): Future[Box[User]] + // Every user answering to this username, whichever provider they came from. Username is only + // unique per provider, so this can return more than one; callers that need a single user must say + // what they do with an ambiguous answer. Added for Berlin Group PSU-ID resolution, where the + // header names a username and the PSU may be federated rather than local. + def getUsersByUsername(userName: String) : List[User] + def getUserByEmail(email: String) : Box[List[ResourceUser]] def getUserByEmailFuture(email: String) : Future[List[(ResourceUser, Box[List[Entitlement]])]] def getUsersByEmail(email: String) : Future[List[(ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]])]] diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala index 1812421fa3..e54e4c0f1f 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala @@ -1,13 +1,16 @@ package code.api.berlin.group.v1_3 +import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ import code.api.berlin.group.v1_3.model.ScaStatusResponse +import code.api.util.APIUtil import code.api.util.APIUtil.OAuth._ import code.api.util.{CallContext, Consent} -import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} +import code.api.util.ErrorMessages.{BerlinGroupPsuNotIdentified, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} import code.consent.{ConsentStatus, Consents} import code.model.TokenType.Access import code.token.Tokens +import code.transactionChallenge.Challenges import net.liftweb.common.{Empty, Full} import net.liftweb.util.Helpers.randomString import net.liftweb.util.TimeHelpers.TimeSpan @@ -127,6 +130,44 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { } } + // The interesting cases here are absences -- no PSU in the session, no header, or both -- and an + // OAuth1-signed test request always attaches a user, so the rule is pinned directly as well as + // driven over HTTP. Same reasoning as the two blocks above. + feature("Consent.resolveBerlinGroupPsu") { + + scenario("a consent that already names a PSU answers for itself", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu(psu, None, None) should equal(Right(psu)) + Consent.resolveBerlinGroupPsu(psu, None, Some(psu)) should equal(Right(psu)) + } + + scenario("an unbound consent takes the PSU from the session, which is the Redirect approach", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu("", Some(psu), None) should equal(Right(psu)) + } + + scenario("with no PSU in the session the PSU-ID header names one, which is Embedded", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu("", None, Some(psu)) should equal(Right(psu)) + } + + // Not a defensive branch: a conforming client-credentials call that omitted the header lands + // here, and there is genuinely no one to mint the challenge for or send the OTP to. + scenario("with none of the three there is nobody to authorise for", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu("", None, None) should equal(Left(BerlinGroupPsuNotIdentified)) + Consent.resolveBerlinGroupPsu(" ", None, Some(" ")) should equal(Left(BerlinGroupPsuNotIdentified)) + } + + // "the ASPSP might check whether PSU-ID and token match" -- Implementation Guidelines V1.3.12, + // section 6.3.1, p.134. Refused rather than resolved by precedence: otherwise a lodging TPP + // could name a third party and have the bound PSU's OTP mailed to them instead. + scenario("a PSU-ID contradicting what the ASPSP already knows is refused", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu(psu, None, Some(otherPsu)) should equal(Left(ConsentDoesNotMatchUser)) + Consent.resolveBerlinGroupPsu("", Some(psu), Some(otherPsu)) should equal(Left(ConsentDoesNotMatchUser)) + } + + scenario("the consent outranks the session when both are present", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu(psu, Some(psu), None) should equal(Right(psu)) + } + } + // resourceUser2 under testConsumer: a genuine second PSU of the *lodging* TPP, which is the only // way to reach the PSU half of the rule over HTTP -- user2 in the shared fixtures carries // testConsumer2, so it would be refused on the Consumer half first. @@ -145,15 +186,29 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { private lazy val secondPsuOfTestConsumerSession = Some(consumer, Token(secondPsuOfTestConsumerToken.key.get, secondPsuOfTestConsumerToken.secret.get)) - private def startAuthorisation(consentId: String, session: Option[(Consumer, Token)]) = + private def startAuthorisation( + consentId: String, + session: Option[(Consumer, Token)], + headers: List[(String, String)] = Nil + ) = makePostRequest( (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (session), - """{"scaAuthenticationData":""}""") - - private def answerAuthorisation(consentId: String, authorisationId: String, session: Option[(Consumer, Token)], otp: String) = + """{"scaAuthenticationData":""}""", + headers) + + private def answerAuthorisation( + consentId: String, + authorisationId: String, + session: Option[(Consumer, Token)], + otp: String, + headers: List[(String, String)] = Nil + ) = makePutRequest( (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (session), - s"""{"scaAuthenticationData":"$otp"}""") + s"""{"scaAuthenticationData":"$otp"}""", + headers: _*) + + private def psuIdHeader(userName: String) = List(("PSU-ID", userName)) feature("BG v1.3 - a consent's authorisation sub-resources answer only to the TPP that lodged it") { @@ -254,4 +309,162 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { response.code should equal(201) } } + + /** + * Embedded SCA: the TPP calls, the PSU is named in the PSU-ID header. + * + * These endpoints used to take the challenge's owner from the session principal. Under a + * client_credentials token that principal is the TPP's own auto-vivified pseudo-user, and the + * challenge answer is delivered to whoever the challenge names -- createChallengeInternal mails it + * to getEmailsByUserId(userId) -- so the OTP went to the TPP and the consent bound to the TPP. + * + * The standard puts the PSU's identity in the PSU-ID header: "Shall be transmitted if this Request + * is indicated by startAuthorisationWithPsuIdentification ... and this field has not yet been + * transmitted before" (Implementation Guidelines V1.3.12, section 7.1 Start Authorisation Process, + * p.195). It is not in the body: the psuData object carries passwords only and no identifier at + * all. + */ + feature("BG v1.3 - an Embedded SCA challenge belongs to the PSU, not to the TPP relaying it") { + + scenario("A client-credentials TPP completes SCA for the PSU it names in PSU-ID", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("The TPP starts the authorisation on its own client-credentials session") + val started = startAuthorisation(consentId, clientCredentialsSession, psuIdHeader(resourceUser1Name)) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("The challenge is minted for the named PSU, so the OTP reaches them and not the TPP") + Challenges.ChallengeProvider.vend.getChallenge(authorisationId) + .openOrThrowException("test challenge lookup failed") + .expectedUserId should be (resourceUser1.userId) + + Then("The TPP relays the PSU's OTP and the consent binds to the PSU") + val answered = answerAuthorisation(consentId, authorisationId, clientCredentialsSession, "123") + answered.code should equal(200) + answered.body.extract[ScaStatusResponse].scaStatus should be ("valid") + + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + consent.userId should be (resourceUser1.userId) + consent.status should be (ConsentStatus.valid.toString) + } + + scenario("With no PSU in the session and no PSU-ID there is nobody to mint the challenge for", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val response = startAuthorisation(consentId, clientCredentialsSession) + response.code should equal(401) + response.body.extract[ErrorMessagesBG].tppMessages.head.code should be ("PSU_CREDENTIALS_INVALID") + + Then("The consent is untouched") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + Option(consent.userId).forall(_.isBlank) should be (true) + consent.status should be (ConsentStatus.received.toString) + } + + scenario("A PSU-ID the ASPSP cannot resolve is refused", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val response = startAuthorisation(consentId, clientCredentialsSession, psuIdHeader("no-such-psu-at-this-aspsp")) + response.code should equal(401) + response.body.extract[ErrorMessagesBG].tppMessages.head.code should be ("PSU_CREDENTIALS_INVALID") + + Option(Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId).forall(_.isBlank) should be (true) + } + + // "It might be contained even if an OAuth2 based authentication was performed in a pre-step. In + // this case the ASPSP might check whether PSU-ID and token match, according to ASPSP + // documentation" -- Implementation Guidelines V1.3.12, section 6.3.1, p.134. Taken up here, and + // extended to the consent's own PSU, which is the same fact recorded a step earlier. + scenario("A PSU-ID naming someone other than the consent's PSU cannot redirect the OTP", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("resourceUser1 authorises it") + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + answerAuthorisation( + consentId, started.body.extract[StartConsentAuthorisationJson].authorisationId, user1, "123" + ).code should equal(200) + + Then("The lodging TPP may not now name a different PSU") + val response = startAuthorisation(consentId, clientCredentialsSession, psuIdHeader(resourceUser2Name)) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchUser) + + Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId should be (resourceUser1.userId) + } + + // The consent already records who it belongs to, which is the "not yet contained in a pre-ceeding + // request" case the standard makes PSU-ID conditional on (section 7.2.1, p.206). + scenario("A bound consent needs no PSU-ID: the consent itself already names the PSU", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + answerAuthorisation( + consentId, started.body.extract[StartConsentAuthorisationJson].authorisationId, user1, "123" + ).code should equal(200) + + val restarted = startAuthorisation(consentId, clientCredentialsSession) + restarted.code should equal(201) + Challenges.ChallengeProvider.vend + .getChallenge(restarted.body.extract[StartConsentAuthorisationJson].authorisationId) + .openOrThrowException("test challenge lookup failed") + .expectedUserId should be (resourceUser1.userId) + } + + scenario("A challenge minted on one consent cannot be answered on another", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val firstConsentId = createUnclaimedBerlinGroupConsent().consentId + val secondConsentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(firstConsentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + val response = answerAuthorisation(secondConsentId, authorisationId, user1, "123") + response.code should equal(400) + + Then("Neither consent was bound") + Option(Consents.consentProvider.vend.getConsentByConsentId(secondConsentId) + .openOrThrowException("test consent lookup failed").userId).forall(_.isBlank) should be (true) + } + } + + // The GET siblings were brought to UserOrApplication on their own; these two were held back + // because a doc's auth mode says nothing useful until the handler has an answer to which PSU an + // authorisation is for. They now do, and the answer does not come from the session, so the docs + // can state what these calls have always been: the TPP acting as itself. + // + // Pinned because nothing else would notice a revert -- these keep working for as long as OAuth2 + // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day + // that stops. + feature("BG v1.3 - the consent authorisation pair accepts a client-credentials caller") { + val authorisationDocs = List( + "startConsentAuthorisationTransactionAuthorisation", + "startConsentAuthorisationUpdatePsuAuthentication", + "startConsentAuthorisationSelectPsuAuthenticationMethod", + "updateConsentsPsuDataTransactionAuthorisation", + "updateConsentsPsuDataUpdatePsuAuthentication", + "updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod", + "updateConsentsPsuDataUpdateAuthorisationConfirmation" + ) + for (name <- authorisationDocs) { + scenario(s"$name declares UserOrApplication", BerlinGroupV13ConsentAccess) { + val docs = APIUtil.ResourceDoc.getResourceDocs( + List(APIUtil.buildOperationId(ConstantsBG.berlinGroupVersion1, name))) + docs should not be empty + docs.foreach(_.authMode should equal(APIUtil.UserOrApplication)) + } + } + } } From 5267b1731155e73076f7f11bba8a1bd807cd9a99 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 7 Aug 2026 11:41:37 +0200 Subject: [PATCH 55/63] fix: resolve the UK consent access check against the PSU, not the session principal (#68) GET and DELETE on account-access-consents refused every caller the standard describes. An authorised consent answered 403 OBP-35023 to the AISP polling it with a client-credentials token, and to a request authenticated by the consent itself -- leaving a PSU-signed token, which a TPP never holds, as the only way in. The self-service poll and revoke the endpoints exist for were unreachable. checkUKConsentAccess was not the problem: it already skips the PSU comparison for a caller with no PSU and judges it on the lodging Consumer, and every one of those combinations is unit-tested. The problem is that no caller could produce that input. The four call sites passed cc.user, which is never Empty on a request that reaches these handlers -- a client-credentials token auto-vivifies a pseudo-user keyed on the consumer's own client key, and applyUKRules swaps in the consent's shadow user -- so the rule was asked about the wrong person and the comparison could never match. A well-tested rule kept being handed an identity the tests never covered because no caller could construct one. Consent.actingPsu supplies the missing step: the PSU applyUKRules set aside on consenter if there is one, otherwise whatever genuine PSU the session carries. genuinePsu alone is not enough, because a shadow user's idGivenByProvider is a random UUID rather than the consumer key and so survives that filter -- which is why checkUKConsent already reads consenter at its own PSU comparison, and this follows it. The OBP-native read path had already been through the same fix, and says so at getConsentByConsentId. Consent.assertUKConsentAccess then keeps the rule and the identity it is asked about in one place. The guard was four verbatim copies of the same six lines, and that is how they came to agree on the wrong argument; collapsing them to one call each is what stops the next edit from having to get it right four times. Narrowing is unaffected: a session acting as a different PSU is still refused with ConsentDoesNotMatchUser, and a second TPP with ConsentDoesNotMatchConsumer. Verified against a running instance for both versions and all three credentials: the six calls that returned 403 now return 200, while a different PSU, a different TPP, and DELETE by a different PSU stay refused. Hola's v4.0.1 consent panel, which showed the 403 in place of the consent status, now renders status, permissions and expiry. Both halves of actingPsu are mutation-checked: dropping consenter reds 3 scenarios, dropping the pseudo-user filter reds 2. --- .../v3_1_0/Http4sUKOBv310AccountAccess.scala | 14 +-- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 14 +-- .../scala/code/api/util/ConsentUtil.scala | 42 +++++++ .../UKOpenBankingV401ConsentAccessTests.scala | 103 +++++++++++++++++- 4 files changed, 148 insertions(+), 25 deletions(-) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 18d16ba435..225686d143 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -190,12 +190,7 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } - _ <- Consent.checkUKConsentAccess( - consent.userId, consent.consumerId, - cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { - case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) - case None => Future.successful(true) - } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -229,12 +224,7 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } - _ <- Consent.checkUKConsentAccess( - consent.userId, consent.consumerId, - cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { - case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) - case None => Future.successful(true) - } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index bfbc9bba3a..fb1d20a7f9 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -229,12 +229,7 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } - _ <- Consent.checkUKConsentAccess( - consent.userId, consent.consumerId, - cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { - case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) - case None => Future.successful(true) - } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } @@ -280,12 +275,7 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } - _ <- Consent.checkUKConsentAccess( - consent.userId, consent.consumerId, - cc.user.toOption.map(_.userId), cc.consumer.map(_.consumerId.get)) match { - case Some(reason) => Helper.booleanToFuture(reason, 403, Some(cc))(false) - case None => Future.successful(true) - } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) } 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 b616f0fc19..6847cd36cb 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -22,6 +22,7 @@ import code.model.Consumer import code.model.dataAccess.BankAccountRouting import code.scheduler.ConsentScheduler.currentDate import code.users.Users +import code.util.Helper import code.util.Helper.MdcLoggable import code.views.Views import com.nimbusds.jwt.JWTClaimsSet @@ -1552,6 +1553,47 @@ object Consent extends MdcLoggable { def genuinePsu(callContext: CallContext): Option[User] = callContext.user.toOption.filterNot(u => callContext.consumer.map(_.key.get).contains(u.idGivenByProvider)) + /** + * The PSU a caller is acting as, or None when it is acting only as itself. + * + * genuinePsu answers this for every credential except one: a request authenticated by the consent + * itself. applyUKRules swaps callContext.user to the consent's shadow user and sets aside the real + * PSU on consenter, and a shadow user's idGivenByProvider is a random UUID rather than the + * consumer key, so genuinePsu waves it through as if it were a person. An ownership check handed + * that principal compares the shadow user against the consent's owner and can never match -- + * checkUKConsent already reads consenter for exactly this reason, and says so at its own PSU + * comparison. + * + * So: the PSU the swap set aside if there is one, otherwise whatever genuine PSU the session + * carries. Both absent is the standard's own AISP call -- a client-credentials token with no PSU + * anywhere -- and None is the answer that lets checkUKConsentAccess fall through to the Consumer + * rule, which is the whole of the authorisation there. + */ + def actingPsu(callContext: CallContext): Option[User] = + callContext.consenter.toOption.orElse(genuinePsu(callContext)) + + /** + * The whole guard the four UK consent-by-id endpoints apply: resolve who the caller is acting as, + * put that to checkUKConsentAccess, and refuse with 403 when it says so. + * + * The rule and the identity it is asked about belong together. Keeping them apart is what let the + * four sites settle on callContext.user -- a value that is never absent on a request reaching + * them, so the rule's own "caller with no PSU" branch was unreachable from every one of them, + * however well that branch was tested in isolation. + */ + def assertUKConsentAccess( + consentUserId: String, + consentConsumerId: String, + callContext: CallContext + ): Future[Box[Unit]] = { + val refusal = checkUKConsentAccess( + consentUserId, consentConsumerId, + actingPsu(callContext).map(_.userId), callContext.consumer.map(_.consumerId.get)) + // booleanToFuture only reads failMsg when the statement is false, so the empty default is never + // the message anyone sees. + Helper.booleanToFuture(refusal.getOrElse(""), 403, Some(callContext))(refusal.isEmpty) + } + /** * Resolve the PSU a Berlin Group consent authorisation is for, returning that PSU's user id or the * reason to refuse. diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala index 74859b7d46..a76a9fbdd9 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala @@ -1,9 +1,13 @@ package code.api.UKOpenBanking.v4_0_1 import code.api.util.APIUtil.{ResourceDoc, UserOrApplication, buildOperationId} -import code.api.util.Consent +import code.api.util.{CallContext, Consent} import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} +import code.model.UserX +import code.model.dataAccess.ResourceUser import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.{Empty, Full} +import net.liftweb.util.Helpers.randomString import org.scalatest.Tag // Who may read or revoke a UK account-access-consent. @@ -34,6 +38,34 @@ class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { private val tpp = "lodging-consumer-id" private val otherTpp = "second-consumer-id" + // A client_credentials token carries the caller's own client id in `sub`, and OAuth2's + // getOrCreateResourceUser turns `sub` into idGivenByProvider -- so the token resolves cc.user to an + // auto-vivified pseudo-user keyed on the consumer's client key rather than leaving it Empty. The + // OAuth1-signed harness cannot mint that token, so build the same shape directly. + private lazy val pseudoUserOfConsumer: ResourceUser = + getOrCreateUser(idGivenByProvider = testConsumer.key.get, name = testConsumer.key.get) + + // What applyUKRules puts on cc.user for a request authenticated by the consent itself: a user + // minted from the consent JWT's `sub`, which is a random UUID per consent. Nothing about it says + // "not a person" -- that is precisely why genuinePsu cannot filter it and consenter is needed. + private lazy val shadowUserOfConsent: ResourceUser = + getOrCreateUser(idGivenByProvider = s"uk-consent-shadow-${randomString(16)}", name = "") + + private def getOrCreateUser(idGivenByProvider: String, name: String): ResourceUser = + UserX.findByProviderId(provider = defaultProvider, idGivenByProvider = idGivenByProvider) + .map(_.asInstanceOf[ResourceUser]) + .getOrElse { + UserX.createResourceUser( + provider = defaultProvider, + providerId = Some(idGivenByProvider), + createdByConsentId = None, + name = Some(name), + email = Some(s"${randomString(10)}@example.com"), + userId = None, + company = None + ).openOrThrowException(s"test user creation failed for $idGivenByProvider") + } + feature("Consent.checkUKConsentAccess") { scenario("the PSU a consent is bound to may use it", UKOpenBankingV401ConsentAccess) { @@ -93,6 +125,75 @@ class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { } } + // The rule above is only ever as good as the identity handed to it, and that is where this family + // was actually failing: every combination with `None` for the caller is tested there, and no + // caller could produce one. cc.user is never Empty on a request that reaches these handlers -- a + // client-credentials token auto-vivifies a pseudo-user, and consent-header authentication swaps in + // the consent's shadow user -- so the rule was being asked about the wrong person and an + // authorised consent answered 403 ConsentDoesNotMatchUser to both callers the standard describes. + // + // Consent.actingPsu is the missing step. These pin the four shapes a caller can arrive in, and the + // last scenario pins the composition, which is the part that regressed rather than either half. + feature("Consent.actingPsu") { + + scenario("a session with no user at all is acting as nobody", UKOpenBankingV401ConsentAccess) { + Consent.actingPsu(CallContext(user = Empty, consumer = Full(testConsumer))) should equal(None) + } + + scenario("a client-credentials caller is acting only as itself", UKOpenBankingV401ConsentAccess) { + // The AISP call the standard describes. None is the right answer, not a missing one: it is + // what lets checkUKConsentAccess fall through to the Consumer rule. + Consent.actingPsu( + CallContext(user = Full(pseudoUserOfConsumer), consumer = Full(testConsumer))) should equal(None) + } + + scenario("a real person authenticated in the session is the PSU", UKOpenBankingV401ConsentAccess) { + Consent.actingPsu(CallContext(user = Full(resourceUser1), consumer = Full(testConsumer))) + .map(_.userId) should equal(Some(resourceUser1.userId)) + } + + scenario("under consent-header authentication the PSU is the one the swap set aside", UKOpenBankingV401ConsentAccess) { + // applyUKRules leaves the consent's shadow user on `user` and the real PSU on `consenter`. A + // shadow user's idGivenByProvider is a random UUID rather than the consumer key, so genuinePsu + // alone waves it through -- this is the case that needs consenter. + val consentHeaderContext = CallContext( + user = Full(shadowUserOfConsent), consenter = Full(resourceUser1), consumer = Full(testConsumer)) + + Consent.actingPsu(consentHeaderContext).map(_.userId) should equal(Some(resourceUser1.userId)) + Consent.genuinePsu(consentHeaderContext).map(_.userId) should equal(Some(shadowUserOfConsent.userId)) + } + + scenario("the consenter outranks the session principal whenever both are present", UKOpenBankingV401ConsentAccess) { + Consent.actingPsu(CallContext( + user = Full(resourceUser2), consenter = Full(resourceUser1), consumer = Full(testConsumer))) + .map(_.userId) should equal(Some(resourceUser1.userId)) + } + + scenario("the composition the endpoints perform lets both standard callers through", UKOpenBankingV401ConsentAccess) { + // A consent bound to resourceUser1 and lodged by testConsumer, reached the two ways a TPP can + // reach it. Both used to be refused with ConsentDoesNotMatchUser. + val bound = resourceUser1.userId + val lodger = testConsumer.consumerId.get + + val viaClientCredentials = + CallContext(user = Full(pseudoUserOfConsumer), consumer = Full(testConsumer)) + Consent.checkUKConsentAccess( + bound, lodger, Consent.actingPsu(viaClientCredentials).map(_.userId), Some(lodger)) should equal(None) + + val viaConsentHeader = CallContext( + user = Full(shadowUserOfConsent), consenter = Full(resourceUser1), consumer = Full(testConsumer)) + Consent.checkUKConsentAccess( + bound, lodger, Consent.actingPsu(viaConsentHeader).map(_.userId), Some(lodger)) should equal(None) + + // And it still narrows: a session acting as a different PSU cannot reach the consent, which is + // the whole reason the user half is kept. + val viaOtherPsu = CallContext(user = Full(resourceUser2), consumer = Full(testConsumer)) + Consent.checkUKConsentAccess( + bound, lodger, Consent.actingPsu(viaOtherPsu).map(_.userId), Some(lodger)) should + equal(Some(ConsentDoesNotMatchUser)) + } + } + feature("consent-by-id ResourceDocs accept a client-credentials caller") { // Without this the docs default to UserOnly, which sends ResourceDocMiddleware down // anonymousAccess and 401s any request carrying no user -- so the rule above would never be From 75a4ac6f5b217ccb03cfd30eb50a70f1464a8a85 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 7 Aug 2026 12:31:42 +0200 Subject: [PATCH 56/63] fix: refuse a UK consent authorisation before it can claim the consent (#69) POST /obp/v5.1.0/banks/BANK_ID/consents/CONSENT_ID/authorise bound the PSU with updateConsentUser before grantUKConsentAccountAccess had decided whether the request was acceptable, and nothing in the sequence is transactional. A refused attempt therefore claimed the consent for whoever made it: the status stayed AWAITINGAUTHORISATION while mUserId became the caller. That is not a cosmetic leftover. The ConsentDoesNotMatchUser guard at the top of the same endpoint then refuses everyone else, so the genuine PSU can no longer authorise their own consent -- not even start a challenge -- and the lodging TPP loses GET and DELETE on it at the same time, leaving no way back through the API. A consent id is handed to the browser in the authorisation redirect, so it reaches history, referrers and access logs; one failing request from anyone who had seen one was enough to destroy that consent permanently, with no authentication as its intended PSU required. grantUKConsentAccountAccess is the step that rejects an account_id the PSU does not hold, or one that does not exist at this bank, so it now runs first and the three writes follow only once it has passed. Ordering is what has to carry this; there is no transaction to roll back. The reorder is safe for the JWT: grantUKConsentAccountAccess writes the views, and updateConsentUser re-reads the row from the database (MappedConsent.find), so updateUserIdOfBerlinGroupConsentJWT copies the freshly written payload and preserves them. Verified end to end -- after a successful authorisation the consent still returns exactly the selected account. Behaviour change worth noting: a request that is bad in both ways -- wrong OTP and an account the PSU does not hold -- still fails on the OTP, since the SCA check keeps its place ahead of this one. Only the writes moved. Verified against a running instance: a refused authorisation leaves mUserId null and the status AWAITINGAUTHORISATION, and the real PSU can then authorise the same consent normally, which before was permanently impossible. The regression test drives the endpoint over HTTP and asserts the database state rather than the status code -- a 400 was always true, including while the consent was being claimed. Restoring the old order reds it on exactly that assertion. --- .../scala/code/api/v5_1_0/Http4s510.scala | 25 ++++++-- .../UKOpenBankingV401AccountInfoTests.scala | 62 +++++++++++++++++-- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 1609c0ca32..045db59c5f 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4408,6 +4408,22 @@ object Http4s510 { _ <- Helper.booleanToFuture(s"$InvalidChallengeAnswer", 403, Some(cc)) { challenge.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised) } + // Bind the consented permissions to the PSU-selected accounts, replacing the + // (bank_id=null, account_id=null) dead views createUKConsentJWT wrote at consent + // creation time. + // + // This runs before anything is written because it is the last step that can still + // refuse the request: it rejects an account_id the PSU does not hold + // (ConsentAccountNotHeldByUser) or one that does not exist at this bank. It used to run + // after updateConsentUser, and a refused authorisation therefore left the consent bound + // to whoever attempted it -- status still AWAITINGAUTHORISATION, mUserId now the + // caller. The ConsentDoesNotMatchUser guard above then locked the real PSU out of their + // own consent, and the lodging TPP lost it too, with no way back through the API. A + // consent id travels to the browser in the authorisation redirect, so a single failing + // request from anyone who had seen one was enough. Nothing here is transactional, so + // ordering is what has to carry it: refuse first, commit afterwards. + _ <- Consent.grantUKConsentAccountAccess(user, BankId(bankIdStr), authJson.account_ids, consent, Some(cc)) + .map(i => connectorEmptyResponse(i, Some(cc))) // Bind the PSU as the consent's user in the DB (mUserId). consentAfterBind <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, user)) .map(i => connectorEmptyResponse(i, Some(cc))) @@ -4416,16 +4432,13 @@ object Http4s510 { // rather than the client_credentials pseudo-user the consent was lodged under. // Despite its name, updateUserIdOfBerlinGroupConsentJWT only rewrites // createdByUserId (via ConsentJWT.copy) — the UK permission views are preserved. + // updateConsentUser re-reads the row from the database, so the JWT copied here is the + // one grantUKConsentAccountAccess just wrote, views and all. updatedJwt <- Future(Consent.updateUserIdOfBerlinGroupConsentJWT(user.userId, consentAfterBind, Some(cc))) .map(i => connectorEmptyResponse(i, Some(cc))) consentWithUser <- Future(Consents.consentProvider.vend.setJsonWebToken(consentId, updatedJwt)) .map(i => connectorEmptyResponse(i, Some(cc))) - // Bind the consented permissions to the PSU-selected accounts, replacing the - // (bank_id=null, account_id=null) dead views createUKConsentJWT wrote at consent - // creation time, and eagerly grant the corresponding AccountAccess rows. - consentWithAccountAccess <- Consent.grantUKConsentAccountAccess(user, BankId(bankIdStr), authJson.account_ids, consentWithUser, Some(cc)) - .map(i => connectorEmptyResponse(i, Some(cc))) - updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentWithAccountAccess.consentId, ConsentStatus.AUTHORISED)) + updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentWithUser.consentId, ConsentStatus.AUTHORISED)) .map(i => connectorEmptyResponse(i, Some(cc))) } yield ConsentJsonV310(updatedConsent.consentId, updatedConsent.jsonWebToken, updatedConsent.status) } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index c210608603..04c50f6b0b 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -435,9 +435,11 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { // the security-relevant rejection branches the re-auth relaxation introduces. private def scaChallengeRequest(consentId: String) = (baseRequest / "obp" / "v5.1.0" / "banks" / testBankId1.value / "consents" / consentId / "authorise" / "challenge").POST - private def createUKConsent(user: com.openbankproject.commons.model.User, expiration: Option[java.util.Date]): String = { + // Option, because the state that matters for the authorise guards is the one a TPP actually + // lodges: no PSU bound yet. saveUKConsent writes mUserId straight from this. + private def createUKConsent(user: Option[com.openbankproject.commons.model.User], expiration: Option[java.util.Date]): String = { val consent = Consents.consentProvider.vend.saveUKConsent( - user = Some(user), bankId = None, accountIds = None, consumerId = None, + user = user, bankId = None, accountIds = None, consumerId = None, permissions = consentPermissions, expirationDateTime = expiration, transactionFromDateTime = None, transactionToDateTime = None, apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") @@ -446,7 +448,7 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } feature("UKOB v4.0.1 re-authentication guards on the SCA challenge endpoint") { scenario("challenge-start on an already-authorised consent bound to a different PSU -> 403", UKOpenBankingV401AccountInfo) { - val consentId = createUKConsent(resourceUser1, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + val consentId = createUKConsent(Some(resourceUser1), Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) Consents.consentProvider.vend.updateConsentUser(consentId, resourceUser1) Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) // user2 != the bound resourceUser1 -> hijack guard rejects @@ -455,7 +457,7 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { response.body.extract[ErrorMessage].message.contains(ConsentDoesNotMatchUser) should equal(true) } scenario("challenge-start on an authorised consent past its ExpirationDateTime -> 400 ConsentExpiredIssue", UKOpenBankingV401AccountInfo) { - val consentId = createUKConsent(resourceUser1, Some(new java.util.Date(System.currentTimeMillis() - 60000L))) + val consentId = createUKConsent(Some(resourceUser1), Some(new java.util.Date(System.currentTimeMillis() - 60000L))) Consents.consentProvider.vend.updateConsentUser(consentId, resourceUser1) Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) val response = makePostRequest(scaChallengeRequest(consentId) <@ (user1), "") @@ -723,4 +725,56 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { getUnauthed("aisp", "transactions").code should equal(401) } } + + // A refused authorisation must leave the consent exactly as it found it. + // + // POST .../consents/CONSENT_ID/authorise used to bind the PSU (updateConsentUser) before + // grantUKConsentAccountAccess had decided whether the request was acceptable at all, and nothing + // in the sequence is transactional. A rejected attempt therefore claimed the consent for whoever + // made it: status still AWAITINGAUTHORISATION, mUserId now the caller. From there the + // ConsentDoesNotMatchUser guard at the top of the same endpoint locked the genuine PSU out of + // their own consent, and the lodging TPP lost its GET/DELETE as well, with no way back through + // the API. Consent ids reach the browser in the authorisation redirect, so one failing request + // from anyone who had seen one was enough to destroy it. + // + // The endpoint asserts the outcome; the DB assertions after it are the actual regression -- a + // 400 alone was always true, and was true while the consent was being claimed. + private def authoriseRequest(consentId: String) = + (baseRequest / "obp" / "v5.1.0" / "banks" / testBankId1.value / "consents" / consentId / "authorise").POST + private def storedConsent(consentId: String) = + Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException(s"consent $consentId") + // An unbound consent stores mUserId as null rather than "", so read it defensively -- the + // assertion is "nobody", and both spellings mean that. + private def boundPsuOf(consentId: String): String = + Option(storedConsent(consentId).userId).map(_.trim).getOrElse("") + + feature("UKOB v4.0.1 a refused authorisation does not claim the consent") { + scenario("account_ids naming an account the PSU does not hold -> refused, consent left unbound", UKOpenBankingV401AccountInfo) { + // The dummy SCA answer is `123` only when this is set; test props leave it unset, so the + // challenge would otherwise refuse before the account check is ever reached. Same as the + // Berlin Group authorisation scenarios do. + setPropsValues("suggested_default_sca_method" -> "DUMMY") + // Lodged the way a TPP lodges one: no PSU yet. + val consentId = createUKConsent(None, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + boundPsuOf(consentId) should equal("") + + // testAccountId1 belongs to resourceUser1, so user2 authorising against it is the refusal. + code.accountholders.AccountHolders.accountHolders.vend + .getAccountsHeld(testBankId1, resourceUser2) + .contains(BankIdAccountId(testBankId1, testAccountId1)) should equal(false) + + val challenge = makePostRequest(scaChallengeRequest(consentId) <@ (user2), "") + challenge.code should equal(200) + val challengeId = (challenge.body \ "challenge_id").extract[String] + + val refused = makePostRequest(authoriseRequest(consentId) <@ (user2), + s"""{"account_ids":["$acc"],"challenge_id":"$challengeId","answer":"123"}""") + refused.code should not equal 200 + refused.body.extract[ErrorMessage].message should include("OBP-35037") + + // The regression: before the reorder both of these came back naming resourceUser2. + boundPsuOf(consentId) should equal("") + storedConsent(consentId).status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + } } From 660270bbed3de063dfaeb891a0268f8728fbc979 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 8 Aug 2026 14:36:59 +0200 Subject: [PATCH 57/63] fix: OBP-Hola five-flow hardening (rolling) (#70) * fix: check account holdings when a Berlin Group consent binds, and let Redirect SCA reach it Two problems in the same place, and the first was hiding behind the second. A Berlin Group consent names its accounts at creation: the TPP lists IBANs in the access object and createBerlinGroupConsentJWT resolves each to a (bank_id, account_id) view before any PSU is involved. Nothing then checked that the PSU who authorises it has anything to do with those accounts. Measured against a running instance: a consent naming another customer's IBAN, authorised by a PSU who does not hold it, bound and served that account's details and balances to the TPP. UK closed this at its own authorise step; Berlin Group never had it. Consent.assertBerlinGroupConsentAccountsHeld reads the accounts off the consent JWT -- the same views the read path will materialise, so the two cannot disagree -- and refuses with ConsentAccountNotHeldByUser. It runs at both authorisation steps: on the POST before the challenge is minted, since an OTP for a consent that can never bind would only deliver a code to someone the TPP nominated; and again on the PUT before any write, because the two are separate requests and nothing in that sequence is transactional. The second problem is why the first stayed invisible: the Consumer half of checkBerlinGroupConsentAccess refused Redirect SCA outright. The standard's same-TPP rule binds "methods submitted by a TPP" (Implementation Guidelines 4.11), but under Redirect the PSU authenticates at the ASPSP and the call arrives from the ASPSP's own front end -- not a TPP, and never the Consumer that lodged the consent. The scaRedirect ceremony could not complete at all. Nothing in the request separates that front end from a second TPP holding a PSU session, so the ASPSP declares its own: berlin_group_sca_front_end_consumer_ids, empty by default, which leaves the same-TPP rule applying to every caller. A declared front end skips that half only; a consent already bound to a PSU still re-binds to that PSU alone. The Consumer check was never what protected the consent -- the TPP that lodged it passes by definition, and is the party the access accrues to. That is what the holdings check is for, and why the two changes belong together. Verified end to end through the Portal: the scaRedirect ceremony now completes (POST authorisations 201, PUT 200, consent valid and owned by the real PSU) and account, balance and transaction reads all return. Still refused: a consent naming an IBAN the claiming PSU does not hold (403, and the consent is left untouched), and an authorisation started by a Consumer that neither lodged the consent nor is a declared front end (403). * fix: report a refused UK consent authorisation as itself, not as a connector fault grantUKConsentAccountAccess returns a Failure carrying its own reason -- an account_id the PSU does not hold, or one that does not exist at this bank. Passing that Box through connectorEmptyResponse rewrote every one of them into InvalidConnectorResponse at 400, so what reached the TPP was OBP-50200: Connector cannot return the data we requested. connectorEmptyResponse <- OBP-35037: One or more of the specified account_ids is not held by ... An authorisation decision presented as a connector fault, with the actual reason trailing behind a cause it has nothing to do with, and a status code that says the request was malformed rather than refused. A Failure here is the same kind of answer as the ConsentDoesNotMatchUser guard a few lines above, so it now gets the same treatment: its own message, at 403. Only a genuinely empty Box is still treated as a connector problem, which is what connectorEmptyResponse is for. The existing regression test asserted `code should not equal 200`, which was true of the wrapped 400 as well; it now pins 403 and that neither OBP-50200 nor connectorEmptyResponse appears in the message. Mutation-checked: restoring connectorEmptyResponse reds it on the status assertion. Regression: code.api.UKOpenBanking 394/394, code.api.v5_1_0 245/245, code.api.berlin.group 180/180. * fix: refuse a non-UK consent at a UK endpoint instead of throwing Presenting an OBP-native consent to a UK Open Banking endpoint came back as 500 OBP-50000: Unknown Error.: Not found http request header 'Authorization', it is mandatory. A server fault for a request that was merely not entitled, and the Berlin Group side already answers the mirror case cleanly with OBP-35036. The shape of such a request explains the throw. The auth dispatcher routes the consent into its own standard's branch, that branch authenticates the request, and ukConsentId is left unset because applyUKRules never ran. checkUKConsent then finds no Authorization header to read a consent_id claim from, and threw rather than returning the Box it is declared to return. It now returns Failure(ConsentDoesNotMatchStandard), which is the same refusal the Berlin Group path gives for a UK consent, so the two standards answer their mirror cases the same way. Verified against a running instance: 500 becomes 403 OBP-35036, a UK consent at the same endpoint still returns 200, and a request carrying nothing at all is still 401. The short-circuit for consent-header authentication is untouched and now has a test of its own, since that is the path the refusal must not swallow. Mutation-checked: restoring the throw reds the new scenario with the RuntimeException itself. Regression: code.api.UKOpenBanking 396/396, code.api.berlin.group 180/180, code.api.v5_1_0 245/245. * fix: keep the internal principal out of Berlin Group view refusals A refused Berlin Group read answered with OBP-20060: User does not have access to the view: ReadBalancesBerlinGroup userId : 8989636c-3879-4f6d-86cc-e79e8d44e388. account : 56fb36df-... Under consent authentication that user id is the consent's own shadow user: an internal identifier minted per consent, which the TPP was never party to and cannot act on. The refusal is about a view and an account, both of which the caller named itself, so the message now says only that. The user id is still logged for anyone diagnosing the refusal. Mutation-checked: putting the user id back reds the new scenario. Regression: code.api.berlin.group 180/180 plus the new scenario, code.api.UKOpenBanking 396/396. * fix: a Berlin Group payment is only addressable by the party that initiated it Berlin Group names a payment by its id alone -- /{paymentService}/{paymentProduct}/ {paymentId} carries no account -- so nothing in the route tied a payment to its caller. Every payment-scoped route fetched it by id and went ahead. A paymentId was therefore a bearer token: any authenticated TPP holding one could read the payment and its status, list its authorisations, start an authorisation on it, and cancel it. Starting an authorisation is the serious one: the challenge is minted for the caller's own user id, so the second TPP could then answer it and execute someone else's payment. Under NextGenPSD2 a payment initiation resource belongs to the TPP that created it, and only that TPP addresses it afterwards. The initiating identity was already being recorded on the payment (user_id, plus on_behalf_of_user_id when it was lodged under a consent); nothing read it back. Fetching now goes through getOwnPaymentImpl, which compares those two against the two the caller presents -- its principal and, under consent authentication, the PSU it is acting for. 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 and is refused. All eleven payment-scoped routes go through it, including GET /{paymentId}/cancellation-authorisations, which previously listed a payment's cancellation authorisation ids without fetching the payment at all. That endpoint now answers a non-existent paymentId the way its ten siblings already did, rather than with an empty list; its scenario is updated to match. Mutation-checked: making the guard always pass reds the new scenario on the first refusal it asserts. Regression: code.api.berlin.group 182/182. * fix: report the direction of a UK transaction instead of always saying Credit UK Open Banking splits a signed amount in two. Amount is unsigned -- the pattern for OBActiveCurrencyAndAmount_SimpleType is ^\d{1,13}$|^\d{1,13}\.\d{1,5}$, which no negative string matches -- and the direction sits beside it in CreditDebitIndicator (OBCreditDebitCode: Credit | Debit). OBP holds the same fact the other way round, as one signed BigDecimal. Neither half was being done. Every factory passed the signed number straight into Amount and hardcoded "Credit" next to it, so a debit of 25 was reported as a credit of -25: wrong in both fields at once, and non-conformant in Amount whatever the direction. A TPP reading the account could not tell money in from money out. UKAmounts does the split once, shared by the v2.0, v3.1 and v4.0.1 factories rather than copied into each, and every transaction and balance now goes through it. Zero is a credit, which the standard states explicitly. A balance OBP holds as a string that will not parse is passed through untouched rather than turned into a fabricated zero. The "Credit" defaults are gone from the two case classes as well: a default is how the literal reached every debit in the first place, so the direction now has to be supplied at each construction site. This is the CreditDebitIndicator half of UK-1. The other half -- ReadTransactionsCredits and ReadTransactionsDebits not filtering the returned list -- is the follow-up already noted at constant.scala:685-694, and depended on this. Mutation-checked: restoring the literal and the signed amount reds 4 of the 7 new scenarios. Regression: code.api.UKOpenBanking 403/403. * fix: count Berlin Group accesses that carry no PSU against frequencyPerDay frequencyPerDay is "the requested maximum frequency for an access without PSU involvement per day", so everything turns on how the ASPSP decides no PSU was involved. NextGenPSD2 settles that with one header: on every AIS read and consent-management call, PSU-IP-Address "shall be contained if and only if this request was actively initiated by the PSU" (parameter PSU-IP-Address_conditionalForAis). isTppRequestsWithoutPsuInvolvement read it the other way round. Only a request carrying PSU-IP-Address: 0.0.0.0 or a no-psu-involved device header counted; a request that simply omitted PSU-IP-Address -- the exact shape the standard reserves for unattended access -- was never counted at all. Since getHeaderValue answers a random long for a header that is absent, absence could not match anything by construction. Each TPP therefore decided whether its own daily limit applied to it, by opting in or not. Absence of PSU-IP-Address is now the declaration it is defined to be. The two sentinels are still honoured, for a TPP that sends the header unconditionally and marks the unattended case in its value instead. Header lookup is case-insensitive, as HTTP requires, and a blank value counts as absent. Mutation-checked: restoring the sentinel-only reading reds the two scenarios about an absent header. Regression: code.api.berlin.group 182/182, code.api.UKOpenBanking 403/403. * fix: spend one frequencyPerDay access per request, not one per middleware A Berlin Group consent asking for four accesses a day got none: the first unattended call answered 429, with usesSoFarTodayCounter already stamped to 4. Measured across several limits, one HTTP request always spent the whole allowance -- frequencyPerDay=2: one request -> 429, counter 0 -> 2 frequencyPerDay=3: one request -> 429, counter 0 -> 3 frequencyPerDay=6: one request -> 429, counter 0 -> 6 -- which is not what checkFrequencyPerDay does. It grants exactly frequencyPerDay accesses. It was simply being asked many times per request. The authentication pipeline runs once per API version in the route chain: each version wraps its own routes in its own ResourceDocMiddleware, and a middleware whose index holds no matching doc still runs best-effort authentication before falling through to the next one (ResourceDocMiddleware's `case None` branch). Roughly ten passes per request, each carrying the Consent-ID header into applyBerlinGroupRules and spending an access. The comment above the call -- "This function MUST be called only once per call" -- states a precondition its caller has never met. Only the middleware that matched a ResourceDoc attaches it to the CallContext, so its presence identifies the pass that will actually serve the request. Both the check and the increment are now gated on that, which is also what "an access" means. The wider consequence of those extra passes -- the whole pipeline, its consent validation, signature verification and database reads, running ~10x per request for every endpoint -- is left alone here: the fallthrough is deliberate, so that is a decision to take on its own rather than a side effect of this fix. Measured against a running server, frequencyPerDay=4: four 200s, then 429, counter following 1,2,3,4. A request carrying the PSU's address is still not counted at all. Regression: code.api.berlin.group 182/182. * fix: hold a Berlin Group payment to the TPP that lodged it, not only to the PSU The ownership guard added in the previous commit compared the people a payment records against the people the caller presents. That leaves the case Berlin Group actually cares about: a payment initiation belongs to the TPP that created it, and two TPPs can serve the same PSU. A second TPP calling with the same PSU's credentials matched on the person and was let straight through -- GET /{paymentId}/status answered 200 under a different consumer key. The scripted probe caught it; nothing in the repository could have, because the payment recorded no consumer to compare against. It records one now, alongside the user ids that were already there, and addressing a payment requires the TPP to match as well as the person. Payments lodged before the column existed carry no consumer and fall back to the person check rather than becoming unaddressable. The new scenario needed a caller shape DefaultUsers does not have -- user2 and user3 change the person as well as the consumer -- so it issues resourceUser1 a token under testConsumer2: same person, different TPP. Mutation-checked: dropping the TPP comparison reds that scenario and only that scenario. Regression: code.api.berlin.group 183/183. * fix: release a VRP mandate's view and limit when its consent is revoked Converting a VRP consent-request builds a private custom view named _vrp-, grants it to the PSU, hangs a counterparty off it and gives that counterparty a limit. Together they are the mandate: the view carries CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, and the limit is how much may be paid under it. Revoking the consent dropped only the shadow user's access. The PSU kept a live standing payment authority for a mandate they had just cancelled, and a set of these accumulated on the account for every mandate ever requested -- one test account had collected three _vrp- views, two of them from consents that were never even used, and 52 limit rows. Nothing in the API removed any of it. Each artefact is named after the view and the view belongs to exactly one consent, so this can be undone without guessing. Revocation now gives back the PSU's grant, deletes the counterparty's limit, and removes the view -- but only once no access row still points at it, which removeCustomView already refuses to do otherwise. So a view something else still holds is left in place rather than orphaned. The counterparty row stays. It is a payee record that settled transactions refer to, and deleting it would take history with it; with the view and the limit gone it grants nothing. The release runs outside the shadow-user lookup and after it. Outside, because a VRP consent that never reached SCA has no shadow user and its mandate still has to be released -- an abandoned mandate is exactly the case that accumulated. After, because the view can only go once every access row is gone, the shadow user's included. Getting this wrong is what the new scenario caught: placed inside the comprehension, it never ran at all for an unauthorised consent. The "_vrp-" prefix now lives in Constant, read by both the conversion that writes it and the revocation that looks for it. Regression: code.api.v5_1_0.VRPConsentRequestTest 7/7 including the new scenario. Both harness probes that measured this now pass against a running server. * fix: give UK v2.0 and v3.1 amounts the member names the standard specifies UK Open Banking writes an amount as {"Amount": "...", "Currency": "..."} -- OBActiveOrHistoricCurrencyAndAmount, both members capitalised. The v2.0 and v3.1 factories emitted {"currency": ..., "amount": ...}, because they reused OBP's shared AmountOfMoneyJsonV121, which spells the same two members in lower case. Every amount in both versions was affected: transaction amounts, charges, instructed amounts, balances and credit lines. v4.0.1 already had its own AmountV401 and was correct. The shared class is used by OBP's own endpoints and cannot be renamed, so the UK responses take their own shape, as v4.0.1 already does. This one was hiding a second defect. A probe that read only the lower-case spelling saw no debit in a v4.0.1 response, concluded there was none to check, and passed -- so the CreditDebitIndicator bug fixed in 944b592e2 stayed green in the harness until the casing was noticed. v2.0's balances also reported the account owner's *name* as the CreditDebitIndicator, in a field the standard restricts to Credit or Debit. It is derived from the balance now, like every other amount here. The Type field on those same balances says "Credit", which is not a member of OBBalanceType1Code either; that one needs a decision about which balance type is meant, so it is left alone and recorded rather than guessed at. Regression: code.api.UKOpenBanking 403/403, code.api.ResourceDocs1_4_0 90/90, code.api.v5_1_0 246/246. No test asserted the lower-case spelling. * fix: resolve an account by a registered OBP routing, not only by the implicit one The OBP account-routing scheme means two things at once. It is an implicit self-identifier -- an address under it is normally the account id, with no row in bankaccountrouting -- but a bank may also register an OBP routing whose address is something else entirely, and that row is stored like any other scheme's. getBankAccountByRoutingLegacy honoured only the implicit reading, so an account with a registered OBP routing was unreachable through every endpoint that resolves by routing. The row was right there in the table and the answer was "Bank Account not found", which is what stopped a consent-request naming an account that way from ever converting: {"scheme":"OBP","address":"hola-testuser01-uk-current"} -> 404 OBP-30073 {"scheme":"OBP","address":"726b08a5-..."} (the account id) -> 201 {"scheme":"IBAN","address":"DE89..."} -> 201 The implicit reading is tried first and still wins wherever both would match, so no address that resolves today resolves differently. The fallback runs only when the implicit reading finds *nothing*. Not when it finds an ambiguity. The first version of this used `or`, which also replaced "this address matches several accounts" with whatever the routing table said -- nothing -- turning a precise complaint into a bare "not found". The new scenario caught that; reading the diff would not have. Mutation-checked: removing the fallback reds the registered-routing scenario alone. * fix: the same OBP-routing blind spot in the plural resolver, which VRP uses Found by filling in the VRP form in a browser rather than by the API probes. Naming the debtor account by its registered OBP routing address failed at consent-request creation, one step earlier than the conversion fixed in the previous commit and in a different function: 404 OBP-30018: Bank Account not found. Please specify valid values for BANK_ID and ACCOUNT_ID. Current BankId is gh.29.uk.x1 and Current AccountId is hola-testuser01-uk-current getBankAccountByRoutings -- the plural one, which createVRPConsentRequest calls -- carries its own copy of the implicit-OBP shortcut and had the same blind spot as the singular resolver. Two copies of one rule, so fixing the first did not fix the second. It now asks the resolver that knows both readings, and still falls back to checkBankAccountExists when neither answers, so a genuinely unknown account reports itself exactly as it did before. Verified end to end afterwards: the VRP consent-request converts, the mandate binds, and revoking it releases the view, the PSU's grant and the limit while leaving the PSU's own ten baseline access rows untouched. Regression: the routing suite 5/5, including a new scenario for the plural resolver. * fix: keep the lodging TPP off the connector wire contract RestConnector_vMar2019_FrozenTest went red, and it was right to. Adding consumer_id to TransactionRequest changed the frozen structure of a type the REST connector sends and receives, so every connector implementor would have seen a new field appear -- for a fact only one server-side guard needs. The field is reverted from obp-commons and from toTransactionRequest. The mConsumerId column stays, because that is where "which TPP lodged this payment" belongs, and the Berlin Group ownership guard reads it straight off the stored row instead. Same behaviour, no change to the connector contract: the frozen test passes again and the Berlin Group suite still holds, including the scenario where a second TPP acting for the same PSU is refused. Regression: code.connector.RestConnector_vMar2019_FrozenTest 5/5, code.api.berlin.group 183/183. * fix: let the ASPSP's own approval screen read a UK consent nobody has claimed yet The UK approval screen showed the PSU a bank and a consent id and nothing else -- no permissions, no status, no expiry -- so they were asked to approve a consent without being told what it granted. The markup was there all along; the data never arrived. The screen fetches the consent to fill those fields, and it arrives under its own Consumer rather than the TPP's. The lodging-Consumer comparison therefore refuses precisely the caller whose job is to inform the PSU: 403 OBP-35015. The loader treats that as non-fatal and renders the page bare, which is why it looked like a display bug. This is the same difficulty the Berlin Group Redirect flow already hit, and it takes the same answer: nothing in a request distinguishes the ASPSP's own screen from a second TPP holding a PSU session, so the ASPSP declares which Consumer is its own. The props key is generalised to sca_front_end_consumer_ids, since it was never Berlin-Group-specific; the old berlin_group_sca_front_end_consumer_ids is still read, so a configured instance needs no edit. Narrow on purpose. It applies only while the consent is unclaimed -- the window the approval screen exists for -- so once a PSU is bound, the PSU comparison governs and a declared front end gets no further than anyone else. It is inert unless an ASPSP declares a front end at all, which is the default. Measured against a running server: the screen now shows AWAITINGAUTHORISATION, the expiry and all four requested permissions, while an undeclared Consumer reading the same unclaimed consent is still refused 403. Mutation-covered by three new scenarios: a declared front end may read an unclaimed consent, may not read a claimed one, and an undeclared caller is still refused. Regression: code.api.UKOpenBanking 406/406, code.api.berlin.group 183/183. * fix: make ReadTransactionsCredits and ReadTransactionsDebits actually restrict the rows These are independently-selectable Permissions in the UK profile, and the ASPSP must refuse a consent that names a transactions depth without at least one of them -- which OBP already does. Then it returned every transaction regardless. A consent granting Credits only still returned the debits, so the PSU's choice of direction was decorative and the TPP saw money going out of an account it had only been permitted to watch coming in. This is the follow-up recorded at constant.scala:685-699. It was blocked on CreditDebitIndicator being a hardcoded literal, since a filter and a label that disagree are worse than neither; that was fixed in 944b592e2, so the direction is now derivable and both read it from the same place. Applied in the endpoint rather than as a can_* permission, as that note decided: direction restricts which rows come back, not which fields are visible, so the view's permission set is the wrong instrument. The two direction views are resolved exactly as Detail-or-Basic already is, and holding both -- or neither -- restricts nothing: neither is the plain Basic case, both is a TPP asking for everything. Shared by v3.1 and v4.0.1 rather than written twice, which is also what keeps the filter and the label from drifting apart. Mutation-checked against a running server: disabling the filter reds exactly the four direction-restricted cases across both versions and leaves the two unrestricted ones green. Repro: .local-testing/OBP-Hola/uk_direction.py, 6/6. Regression: code.api.UKOpenBanking 410/410, code.api.berlin.group 183/183. * fix: address the review findings on this branch Five fixes to my own earlier commits, found by reviewing the branch as a whole. **Direction restriction was applied after the page limit.** The filter trimmed a page the database had already limited, so a direction-restricted consent got a short page it could not tell from the end of the data -- and with Constant.Pagination.limit defaulting to 50, with no pagination parameter from the TPP at all. On an account whose first page is debits, a Credits-only consent saw one row where eleven existed. The restriction is now pushed into the query as OBPTransactionDirection so the database applies it and the limit together; the endpoint filter stays, because a connector other than the mapped one may ignore the param and that filter is what actually enforces the consent's scope. The two-transaction fixture is why the earlier tests could not catch this. The new probe seeds sixty debits ahead of ten credits, and mutation-checking it against the previous behaviour reds all four cases. **A declared SCA front end could reach a consent that already had a PSU.** The Berlin Group guard fell through to the front-end exception whenever the caller presented no PSU, which is exactly what a client-credentials caller presents -- so the exception applied to consents already bound to somebody else, the opposite of what its own paragraph promises. Now conditioned on the consent being unclaimed, as the UK twin already was. **The counterparty-limit deletion was fire-and-forget.** Its Future was discarded and getCounterparties' Box was flattened with getOrElse(Nil), so a failed lookup or a failed delete left the standing limit alive -- the very leak the commit fixes -- while the code logged "released". Both are observed now, and say so when they fail. **unsignedAmount used toString.** BigDecimal renders a negative scale in scientific notation, so BigDecimal("1E+3") came out "1E+3", which the Amount pattern this exists to satisfy rejects. toPlainString. **A superseded scaladoc block** was left stacked above scaFrontEndConsumerIds, still asserting the key is Berlin-Group-specific. New scenarios cover the plain rendering, the query restriction, and that the restriction and the post-filter agree on every amount -- two enforcements of one rule that must not diverge. Regression: code.api.UKOpenBanking 413/413, code.api.berlin.group 183/183, code.api.v5_1_0 246/246, RestConnector_vMar2019_FrozenTest 5/5. * fix: address the second review pass and the duplication gate A cleanup failure must not fail a revoke that already happened. The Await added to observe the counterparty-limit deletion runs after the status flip has committed and after the shadow user's access is gone, and a second attempt is refused with ConsentAlreadyRevoked -- so letting a timeout or a connector failure escape turned a completed revoke into a 500 and abandoned the views still queued behind it. Caught and logged instead. A transaction whose amount the view withheld is now admitted by neither direction. The filter reads already-moderated rows, so a missing amount means the view did not grant CAN_SEE_TRANSACTION_AMOUNT rather than that the amount is zero; creditDebitIndicator maps that to Credit for labelling, which as a permission test handed every debit to a Credits-only consent. The direction boundary now lives once, on OBPTransactionDirection, and both enforcements build from it: the connector's SQL predicate and the endpoint filter the test checks against. The scenario claiming the two agree used to model the query in the test itself, so it agreed with its own copy and could not detect the drift it existed to catch. The v3.1 and v4.0.1 transaction reads were the same thirty lines with a different factory at the end, which is why the direction rule had to be written into both. They now share UKTransactionsQuery and keep only their route and their yield. LocalMappedConnector's two identical query builders are likewise one method. That also clears the duplication gate the previous commit pushed over its threshold. Remote connectors take a frozen outbound message that cannot carry the direction, so they still return both and the filter trims an already-limited page. Nothing here can repair that without the connector, but returning the short page silently is how the defect stayed invisible: a full page that lost rows to the filter is now logged with the connector that needs the param. * fix: refuse listing the authorisations of another TPP's consent GET /consents/CONSENTID/authorisations had passesPsd2Aisp and nothing else, so any AISP caller could list the challenge ids of a consent lodged by somebody else. Its immediate neighbour, GET /consents/CONSENTID, already compares the consent's consumer against the caller's and answers 403 -- two reads of the same consent, twelve lines apart, disagreeing about who may perform them. The PUT that answers a challenge is guarded, so this leaked identifiers rather than access. Guarded the same way as the sibling. * fix: stop answering unrecognised authorisation requests with a canned example Berlin Group hangs four request bodies off one authorisation path, and the handlers dispatch on the body's shape. Only transactionAuthorisation was ever recognised, and the fall-through was a hardcoded example rather than an error -- so anything else got a fabricated success. Two consequences, both reachable by a conforming TPP: An empty body is how the standard starts an authorisation, and it fell to that fall-through. The caller got 201 with the literal authorisationId "123auth456.", then discovered at the PUT that the id matched no challenge. Neither start handler reads scaAuthenticationData -- the POST mints the challenge and the PUT answers it -- so an empty body and a transactionAuthorisation body are the same request here, and both now start a real authorisation. On the PUT, the final else was labelled "authorisationConfirmation variant" but tested nothing, so an unreadable body was answered "scaStatus": "finalised" -- the terminal success state of strong customer authentication -- for an authorisation nothing had happened to. It is guarded by the checker that already existed for it. What remains mocked is what is declared as mocked: the updatePsuAuthentication and selectPsuAuthenticationMethod Embedded steps. Everything else is now a 400 that says which shapes are accepted. None of this was reachable through OBP's own clients, which always send {"scaAuthenticationData": ""} -- the single branch that worked was the only one ever exercised. --- .../resources/props/sample.props.template | 24 ++ .../SwaggerDefinitionsJSON.scala | 16 +- .../code/api/UKOpenBanking/UKAmounts.scala | 116 +++++++++ .../UKOpenBanking/UKTransactionsQuery.scala | 120 +++++++++ .../JSONFactory_UKOpenBanking_200.scala | 49 ++-- .../v3_1_0/Http4sUKOBv310Transactions.scala | 29 +-- .../JSONFactory_UKOpenBanking_310.scala | 81 +++--- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 29 +-- .../JSONFactory_UKOpenBanking_401.scala | 25 +- .../berlin/group/v1_3/Http4sBGv13AIS.scala | 67 ++++- .../berlin/group/v1_3/Http4sBGv13PIS.scala | 131 ++++++++-- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 21 ++ .../scala/code/api/constant/constant.scala | 5 + .../main/scala/code/api/util/APIUtil.scala | 16 ++ .../code/api/util/BerlinGroupCheck.scala | 38 ++- .../scala/code/api/util/ConsentUtil.scala | 244 +++++++++++++++++- .../scala/code/api/util/ErrorMessages.scala | 1 + .../main/scala/code/api/util/OBPParam.scala | 37 +++ .../scala/code/api/v5_0_0/Http4s500.scala | 4 +- .../scala/code/api/v5_1_0/Http4s510.scala | 19 +- .../bankconnectors/LocalMappedConnector.scala | 114 +++++--- .../MappedTransactionRequestProvider.scala | 2 + .../api/UKOpenBanking/UKAmountsTest.scala | 144 +++++++++++ .../UKOpenBankingV401AccountInfoTests.scala | 12 +- .../UKOpenBankingV401ConsentAccessTests.scala | 79 ++++-- .../BerlinGroupV13ConsentAccessTests.scala | 79 ++++-- .../PaymentInitiationServicePISApiTest.scala | 128 ++++++++- .../util/BerlinGroupPsuInvolvementTest.scala | 54 ++++ .../api/v5_1_0/VRPConsentRequestTest.scala | 53 ++++ .../ObpAccountRoutingResolutionTest.scala | 110 ++++++++ 30 files changed, 1603 insertions(+), 244 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala create mode 100644 obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala create mode 100644 obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala create mode 100644 obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala create mode 100644 obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 75e91e94c4..b612de45c0 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1863,3 +1863,27 @@ securelogging_mask_email=true ############################################ # messaging.channel.ttl.seconds=3600 # messaging.channel.max.messages=1000 + +# The Consumers that are this ASPSP's own Strong Customer Authentication front end, comma separated +# consumer ids. Two consent standards need this, for the same reason: the approval screen arrives +# under its own Consumer rather than the TPP's, and nothing in the request tells it apart from a +# second TPP holding a PSU session, so it has to be declared. +# +# Berlin Group -- see the note below, which this key supersedes. +# UK Open Banking -- the screen reads the consent to show the PSU which permissions, status and +# expiry they are being asked to approve. Only while the consent is still unclaimed; once a PSU +# is bound to it, a declared front end gets no further than any other caller. +# +# Leave empty unless you run your own SCA screen: empty keeps the lodging-TPP rule applying to +# everyone. The older berlin_group_sca_front_end_consumer_ids below is still read, so an instance +# already configured for Berlin Group Redirect SCA needs no edit. +# sca_front_end_consumer_ids= + +# Berlin Group: the Consumers that are this ASPSP's own Strong Customer Authentication front end, +# comma separated consumer ids. Under the Redirect approach the PSU authenticates at the ASPSP, so +# the authorisation calls on /berlin-group/v1.3/consents/{id}/authorisations arrive from that front +# end rather than from the TPP that lodged the consent -- and the standard's same-TPP rule +# (Implementation Guidelines 4.11) would otherwise refuse them. Nothing in the request tells the +# ASPSP's own front end apart from a second TPP holding a PSU session, so it has to be declared. +# Leave empty unless you use Redirect SCA: empty keeps the same-TPP rule applying to every caller. +# berlin_group_sca_front_end_consumer_ids= diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 696f2af7a5..72e58fcc88 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5,7 +5,7 @@ import scala.language.implicitConversions import code.api.Constant import code.api.Constant._ import code.api.UKOpenBanking.v2_0_0.JSONFactory_UKOpenBanking_200 -import code.api.UKOpenBanking.v2_0_0.JSONFactory_UKOpenBanking_200.{Account, AccountBalancesUKV200, AccountInner, AccountList, Accounts, BalanceJsonUKV200, BalanceUKOpenBankingJson, BankTransactionCodeJson, CreditLineJson, DataJsonUKV200, Links, MetaBisJson, MetaInnerJson, TransactionCodeJson, TransactionInnerJson, TransactionsInnerJson, TransactionsJsonUKV200} +import code.api.UKOpenBanking.v2_0_0.JSONFactory_UKOpenBanking_200.{Account, AmountUKOpenBankingJson, AccountBalancesUKV200, AccountInner, AccountList, Accounts, BalanceJsonUKV200, BalanceUKOpenBankingJson, BankTransactionCodeJson, CreditLineJson, DataJsonUKV200, Links, MetaBisJson, MetaInnerJson, TransactionCodeJson, TransactionInnerJson, TransactionsInnerJson, TransactionsJsonUKV200} import code.api.dynamic.endpoint.helper.practise.PractiseEndpoint import code.api.util.APIUtil.{defaultJValue, _} import code.api.util.ApiRole._ @@ -554,6 +554,12 @@ object SwaggerDefinitionsJSON { amount = "0" ) + // UK Open Banking spells the same two members capitalised, so its examples take its own shape. + lazy val amountUKOpenBankingJson = AmountUKOpenBankingJson( + Amount = "0", + Currency = "EUR" + ) + lazy val transactionRequestTransferToPhone = TransactionRequestTransferToPhone( value = amountOfMoneyJsonV121, description = "String", @@ -4002,7 +4008,7 @@ object SwaggerDefinitionsJSON { ) lazy val balanceUKOpenBankingJson = BalanceUKOpenBankingJson( - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, CreditDebitIndicator = "Credit", Type = "InterimBooked" ) @@ -4016,7 +4022,7 @@ object SwaggerDefinitionsJSON { AccountId = accountIdSwagger.value, TransactionId = "123", TransactionReference = "Ref 1", - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, CreditDebitIndicator = "Credit", Status = "Booked", BookingDateTime = DateWithDayExampleObject, @@ -4045,13 +4051,13 @@ object SwaggerDefinitionsJSON { lazy val creditLineJson = CreditLineJson( Included = true, - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, Type = "Pre-Agreed" ) lazy val balanceJsonUK200 = BalanceJsonUKV200( AccountId = "22289", - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, CreditDebitIndicator = "Credit", Type = "InterimAvailable", DateTime = DateWithDayExampleObject, diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala new file mode 100644 index 0000000000..ea1c2ef96f --- /dev/null +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala @@ -0,0 +1,116 @@ +package code.api.UKOpenBanking + +import code.api.util.{APIUtil, CallContext, OBPQueryParam, OBPTransactionDirection} +import code.model.ModeratedTransaction +import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, User, ViewId} + +/** + * How UK Open Banking writes a signed amount. + * + * The standard splits sign from magnitude: `Amount` is unsigned — `OBActiveCurrencyAndAmount_SimpleType` + * is `^\d{1,13}$|^\d{1,13}\.\d{1,5}$`, which no negative string matches — and the direction is carried + * beside it in `CreditDebitIndicator` (`OBCreditDebitCode`, `Credit` | `Debit`). OBP holds the same fact + * the other way round, as one signed BigDecimal, so every UK response has to split it. + * + * Shared by the v3.1 and v4.0.1 factories: the two used to hardcode `"Credit"` next to a signed amount, + * which reported a debit of 25 as a credit of -25 — wrong in both fields at once. One copy so they + * cannot drift back apart. + */ +object UKAmounts { + + /** `Credit` or `Debit` for a signed amount. Zero is a credit, as the standard says explicitly. */ + def creditDebitIndicator(amount: BigDecimal): String = + if (amount < 0) "Debit" else "Credit" + + def creditDebitIndicator(amount: Option[BigDecimal]): String = + creditDebitIndicator(amount.getOrElse(BigDecimal(0))) + + /** + * The magnitude, as the unsigned decimal string the `Amount` field's pattern allows. + * + * toPlainString, not toString: BigDecimal renders a negative scale in scientific notation, so + * BigDecimal("1E+3").toString is "1E+3" -- which the pattern this exists to satisfy rejects. + */ + def unsignedAmount(amount: BigDecimal): String = amount.abs.bigDecimal.toPlainString + + def unsignedAmount(amount: Option[BigDecimal]): String = + unsignedAmount(amount.getOrElse(BigDecimal(0))) + + /** + * The same split for an amount OBP already holds as a string (balances come through that way). + * An unparseable value is passed through untouched rather than turned into a fabricated zero. + */ + def unsignedAmountString(amount: String): String = + scala.util.Try(BigDecimal(amount)).map(unsignedAmount).getOrElse(amount) + + def creditDebitIndicatorOfString(amount: String): String = + scala.util.Try(BigDecimal(amount)).map(creditDebitIndicator).getOrElse("Credit") + + /** + * Whether a transaction of this amount is inside the directions a consent granted. + * + * `ReadTransactionsCredits` and `ReadTransactionsDebits` are independently selectable Permissions, + * and they restrict which rows come back rather than which fields are visible -- a PSU who grants + * only Credits sees the same fields, on credit rows alone. Granting both, or neither, places no + * direction restriction: neither is the plain `ReadTransactionsBasic`/`Detail` case, both is the + * TPP asking for everything. + * + * Reads the direction through creditDebitIndicator rather than testing the sign again here, so the + * row a response labels `Debit` is exactly the row this admits under Debits. + * + * A missing amount admits nothing. The input is already moderated, so None means the view withheld + * `CAN_SEE_TRANSACTION_AMOUNT` rather than that the amount is zero -- and creditDebitIndicator maps + * None to `Credit` for labelling, which as a permission test would hand every debit to a + * Credits-only consent. There is no direction to check without the amount, so refuse instead: + * a short response is recoverable, a leaked one is not. + */ + def admitsDirection(amount: Option[BigDecimal], grantsCredits: Boolean, grantsDebits: Boolean): Boolean = + if (grantsCredits == grantsDebits) true + else amount.exists(a => creditDebitIndicator(a) == (if (grantsCredits) "Credit" else "Debit")) + + /** + * Whether the caller holds a given direction view on this account. + * + * A soft check: the answer is a fact about the consent's scope, not a refusal. The endpoints + * already resolved a Detail-or-Basic view to read with, so a caller holding neither direction view + * is not an error -- it simply has no direction restriction. + */ + def grantsView( + viewId: String, + bankId: BankId, + accountId: AccountId, + user: User, + callContext: CallContext + ): Boolean = + APIUtil.checkViewAccessAndReturnView( + ViewId(viewId), BankIdAccountId(bankId, accountId), Some(user), Some(callContext)).isDefined + + /** + * The query restriction matching the directions a consent granted, if any. + * + * Pushed into the query so the database applies the direction and the page limit together. + * Filtering an already-limited page instead would hand the TPP a short page it cannot tell from + * the end of the data, and with an offset would make rows unreachable entirely: a Credits-only + * consent on an account whose first page is all debits would see nothing at all. + * + * None when both directions are granted or neither, which is no restriction. + */ + def directionQueryParam(grantsCredits: Boolean, grantsDebits: Boolean): List[OBPQueryParam] = + if (grantsCredits == grantsDebits) Nil + else List(OBPTransactionDirection(credits = grantsCredits)) + + /** + * admitsDirection over a transaction list, shared by the v3.1 and v4.0.1 endpoints. + * + * Still applied after directionQueryParam has narrowed the query, and deliberately so: a + * connector other than the mapped one may ignore the query param, and this is what enforces the + * consent's scope regardless of what the connector chose to return. + */ + def filterByGrantedDirections( + transactions: List[ModeratedTransaction], + grantsCredits: Boolean, + grantsDebits: Boolean + ): List[ModeratedTransaction] = + if (grantsCredits == grantsDebits) transactions + else transactions.filter(t => admitsDirection(t.amount, grantsCredits, grantsDebits)) +} diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala new file mode 100644 index 0000000000..ed55bf4be3 --- /dev/null +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala @@ -0,0 +1,120 @@ +package code.api.UKOpenBanking + +import code.api.APIFailureNewStyle +import code.api.Constant +import code.api.util.APIUtil.{HTTPParam, createQueriesByHttpParams, fullBoxOrException, passesPsd2Aisp, unboxFull} +import code.api.util.{CallContext, OBPLimit, OBPQueryParam} +import code.api.util.ErrorMessages.UnknownError +import code.api.util.NewStyle +import code.api.util.newstyle.ViewNewStyle +import code.model.{BankAccountExtended, ModeratedTransaction, UserExtended} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{AccountId, Bank, BankAccount, BankIdAccountId, TransactionAttribute, User, View, ViewId} +import net.liftweb.common.Full +import org.http4s.Request +import cats.effect.IO + +import scala.concurrent.Future + +/** + * Reading an account's transactions under a UK Open Banking consent. + * + * v3.1 and v4.0.1 expose the same read at two paths and serialise it with their own JSON factories, + * but everything before that last step is one procedure: check the consent, resolve Detail-or-Basic, + * turn the request into query params, narrow them by the directions the consent granted, fetch, and + * filter what came back. It lived twice, once per version, and the direction rule then had to be + * written twice as well -- the same shape that let an earlier fix land in one copy and not the other. + * + * The versions keep only what actually differs: their route, and the factory they yield through. + */ +object UKTransactionsQuery extends MdcLoggable { + + /** + * Everything the version-specific `yield` needs, once the shared work is done. + * + * `transactions` is already direction-filtered, and `attributes` was fetched for exactly those + * rows, so a caller cannot accidentally serialise the unfiltered list. + */ + case class Result( + account: BankAccount, + view: View, + transactions: List[ModeratedTransaction], + attributes: List[TransactionAttribute] + ) + + /** + * @param req the http4s request, read for its pagination/filter headers + * @param u the authenticated caller, whose consent scope decides the directions + */ + def read(req: Request[IO], u: User, cc: CallContext, accountId: AccountId): Future[Result] = { + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) + for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) + (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) + (bank: Bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) + view <- ViewNewStyle.checkViewsAccessAndReturnView( + detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) + params <- Future { + createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) + } map { x => + unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) + } + // Resolved before the query so the direction can shape it: the database has to apply the + // restriction and the page limit together, or the page is trimmed after the fact and a + // Credits-only consent on a debit-heavy account sees a short page it cannot tell from the end + // of the data. + grantsCredits = UKAmounts.grantsView(Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, account.bankId, accountId, u, cc) + grantsDebits = UKAmounts.grantsView(Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID, account.bankId, accountId, u, cc) + directedParams = params ++ UKAmounts.directionQueryParam(grantsCredits, grantsDebits) + (transactions, _) <- BankAccountExtended(account) + .getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), directedParams) map { x => + unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) + } + // ReadTransactionsCredits / ReadTransactionsDebits restrict which rows the consent may see, + // not which fields, so they are applied here rather than through the view's can_* set. Kept + // alongside the query restriction on purpose: it is what holds the consent's scope when the + // connector ignored the param. + directedTransactions = UKAmounts.filterByGrantedDirections(transactions, grantsCredits, grantsDebits) + _ = warnIfPageWasTrimmed(transactions, directedTransactions, directedParams, cc) + (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( + account.bankId, + directedTransactions.map(_.id), + view.viewId, + Some(cc)) + } yield Result(account, view, directedTransactions, moderatedAttributes) + } + + /** + * Say so when the direction restriction had to be applied after the page limit. + * + * `OBPTransactionDirection` is translated by LocalMappedConnector; the remote connectors take a + * frozen outbound message (see RestConnector_vMar2019_FrozenTest) carrying only limit, offset and + * the date range, so they cannot receive it and return both directions. The filter above then + * removes rows from an already-limited page, and the TPP gets a short page indistinguishable from + * the end of the data -- a Credits-only consent on a debit-heavy account can see nothing at all. + * + * Nothing here can repair that without the connector's cooperation, and silently returning the + * short page is how the defect stayed invisible in the first place. So it is logged: a full page + * that lost rows to the filter is the exact signature, and it tells an operator which connector + * still needs to honour the param. + */ + private def warnIfPageWasTrimmed( + fetched: List[ModeratedTransaction], + kept: List[ModeratedTransaction], + params: List[OBPQueryParam], + cc: CallContext + ): Unit = { + val limit = params.collectFirst { case OBPLimit(value) => value }.getOrElse(Constant.Pagination.limit) + if (kept.size < fetched.size && fetched.size >= limit) { + logger.warn( + s"UK transactions: the direction restriction was applied after the page limit -- " + + s"${fetched.size - kept.size} of $limit rows removed from a full page for consent " + + s"${cc.consumer.map(_.consumerId.get).getOrElse("unknown")}. The connector in use did not " + + s"honour OBPTransactionDirection, so this page is short and the TPP cannot tell. " + + s"Implement the param in that connector to fix the pagination.") + } + } +} diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala index 33fc94c402..0933485bb9 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala @@ -1,11 +1,12 @@ package code.api.UKOpenBanking.v2_0_0 +import code.api.UKOpenBanking.UKAmounts + import java.util.Date import code.api.Constant import code.api.util.APIUtil.DateWithDayExampleObject import code.api.util.CustomJsonFormats -import com.openbankproject.commons.model.AmountOfMoneyJsonV121 import code.model.{ModeratedBankAccount, ModeratedTransaction} import code.model.toBankAccountExtended import com.openbankproject.commons.model.TransactionRequest @@ -51,8 +52,17 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { Issuer: String ) + // UK Open Banking spells this object {"Amount": "...", "Currency": "..."} -- + // OBActiveOrHistoricCurrencyAndAmount, both members capitalised. OBP's shared + // AmountOfMoneyJsonV121 spells the same two in lower case and is used by OBP's own endpoints, so + // it cannot be renamed; the UK responses take their own shape instead. + case class AmountUKOpenBankingJson( + Amount: String, + Currency: String + ) + case class BalanceUKOpenBankingJson( - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String ) @@ -61,7 +71,7 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { AccountId: String, TransactionId: String, TransactionReference: String, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Status: String, BookingDateTime: Date, @@ -84,13 +94,13 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { case class CreditLineJson( Included: Boolean, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, Type: String ) case class BalanceJsonUKV200( AccountId: String, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String, DateTime: Date, @@ -140,11 +150,12 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { AccountId = accountId, TransactionId = transaction.id.value, TransactionReference = transaction.description.getOrElse(""), - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse("") , - amount= transaction.amount.getOrElse(BigDecimal(0)).toString() + // UK keeps the sign out of Amount and in CreditDebitIndicator; OBP holds one signed number. + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse("") , + Amount = UKAmounts.unsignedAmount(transaction.amount) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicator(transaction.amount), Status = "Booked", BookingDateTime = transaction.startDate.get, ValueDateTime = transaction.finishDate.get, @@ -152,11 +163,11 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { BankTransactionCode = BankTransactionCodeJson("",""), ProprietaryBankTransactionCode = TransactionCodeJson("Transfer", "AlphaBank"), Balance = BalanceUKOpenBankingJson( - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse(""), - amount = transaction.balance + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(transaction.balance) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(transaction.balance), Type = "InterimBooked" )) ) @@ -199,13 +210,13 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { val dataJson = DataJsonUKV200( List(BalanceJsonUKV200( AccountId = accountId, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""), moderatedAccount.balance), - CreditDebitIndicator = moderatedAccount.owners.getOrElse(null).head.name, + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance), moderatedAccount.currency.getOrElse("")), + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedAccount.balance), Type = "Credit", DateTime = null, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""),moderatedAccount.balance), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance), moderatedAccount.currency.getOrElse("")), Type = "Pre-Agreed" ))))) @@ -221,13 +232,13 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { val dataJson = DataJsonUKV200( accounts.map(account => BalanceJsonUKV200( AccountId = account.accountId.value, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), - CreditDebitIndicator = account.userOwners.headOption.getOrElse(null).name, + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(account.balance), Type = "Credit", DateTime = null, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), Type = "Pre-Agreed" ))))) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala index bb406c8af5..edb8cfdb6b 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v3_1_0 +import code.api.UKOpenBanking.{UKAmounts, UKTransactionsQuery} + import org.json4s._ import cats.data.{Kleisli, OptionT} import cats.effect.IO @@ -306,28 +308,11 @@ object Http4sUKOBv310Transactions extends MdcLoggable { case req @ GET -> `ukV31Prefix` / "accounts" / accountIdStr / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => val accountId = AccountId(accountIdStr) - val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) - val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) - for { - _ <- NewStyle.function.checkUKConsent(u, Some(cc)) - _ <- passesPsd2Aisp(Some(cc)) - (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) - (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) - view <- ViewNewStyle.checkViewsAccessAndReturnView(detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) - params <- Future { - createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) - } map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (transactions, _) <- BankAccountExtended(account).getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), params) map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( - account.bankId, - transactions.map(_.id), - view.viewId, - Some(cc)) - } yield JSONFactory_UKOpenBanking_310.createTransactionsJsonNew(account.bankId, transactions, moderatedAttributes, view) + // The read itself is shared with v4.0.1 -- see UKTransactionsQuery. Only the factory differs. + UKTransactionsQuery.read(req, u, cc, accountId) map { result => + JSONFactory_UKOpenBanking_310.createTransactionsJsonNew( + result.account.bankId, result.transactions, result.attributes, result.view) + } } } resourceDocs += ResourceDoc( diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala index ef9c7e986d..6d371a675f 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v3_1_0 +import code.api.UKOpenBanking.UKAmounts + import org.json4s._ import java.util.Date @@ -7,7 +9,7 @@ import code.api.Constant import code.api.util.APIUtil.DateWithDayExampleObject import code.api.util.CustomJsonFormats import code.model.{ModeratedBankAccount, ModeratedBankAccountCore, ModeratedTransaction} -import com.openbankproject.commons.model.{AccountAttribute, AccountId, AmountOfMoneyJsonV121, BankAccount, BankId, TransactionAttribute, TransactionId, TransactionRequest, View} +import com.openbankproject.commons.model.{AccountAttribute, AccountId, BankAccount, BankId, TransactionAttribute, TransactionId, TransactionRequest, View} import org.json4s.JsonAST.JObject import scala.collection.immutable.List @@ -72,8 +74,18 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { Issuer: String ) + // UK Open Banking spells this object {"Amount": "...", "Currency": "..."} -- + // OBActiveOrHistoricCurrencyAndAmount, both members capitalised. OBP's shared + // AmountOfMoneyJsonV121 spells the same two in lower case and is used by OBP's own endpoints, so + // it cannot be renamed; the UK responses take their own shape instead, as v4.0.1 already does + // with AmountV401. + case class AmountUKOpenBankingJson( + Amount: String, + Currency: String + ) + case class BalanceUKOpenBankingJson( - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String = "ClosingAvailable" ) @@ -85,7 +97,7 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { ExchangeRate: Int, ContractIdentification: String, QuotationDate: Date, - InstructedAmount: AmountOfMoneyJsonV121 + InstructedAmount: AmountUKOpenBankingJson ) case class CardInstrumentJson( @@ -132,13 +144,15 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { TransactionId: String, TransactionReference: String, StatementReference: List[String] = List("String"), - Amount: AmountOfMoneyJsonV121, - CreditDebitIndicator: String ="Credit", + Amount: AmountUKOpenBankingJson, + // No default: the direction has to be derived from the amount at every construction site + // (see UKAmounts). A default is how "Credit" ended up on every debit. + CreditDebitIndicator: String, Status: String ="Booked", BookingDateTime: Date, ValueDateTime: Date, AddressLine: String = "String", - ChargeAmount: AmountOfMoneyJsonV121, + ChargeAmount: AmountUKOpenBankingJson, TransactionInformation: String, CurrencyExchange:CurrencyExchangeJson, BankTransactionCode: BankTransactionCodeJson, @@ -165,13 +179,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { case class CreditLineJson( Included: Boolean, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, Type: String ) case class BalanceJsonUKV310( AccountId: String, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String, DateTime: Date, @@ -313,12 +327,14 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { accountId, transaction.id.value, TransactionReference = transaction.description.getOrElse(""), - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse("") , - amount= transaction.amount.getOrElse(BigDecimal(0)).toString()), + // UK keeps the sign out of Amount and in CreditDebitIndicator; OBP holds one signed number. + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse("") , + Amount = UKAmounts.unsignedAmount(transaction.amount)), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(transaction.amount), BookingDateTime = transaction.startDate.get, ValueDateTime = transaction.finishDate.get, - ChargeAmount = AmountOfMoneyJsonV121(transaction.currency.getOrElse(""),"0"), + ChargeAmount = AmountUKOpenBankingJson("0", transaction.currency.getOrElse("")), TransactionInformation = transaction.description.getOrElse(""), CurrencyExchange = CurrencyExchangeJson( SourceCurrency = transaction.bankAccount.map(_.currency).flatten.getOrElse(""), @@ -327,16 +343,16 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { ExchangeRate = 0, ContractIdentification = "string", QuotationDate = new Date(), - InstructedAmount = AmountOfMoneyJsonV121(transaction.bankAccount.map(_.currency).flatten.getOrElse(""),"")), + InstructedAmount = AmountUKOpenBankingJson("", transaction.bankAccount.map(_.currency).flatten.getOrElse(""))), BankTransactionCode = BankTransactionCodeJson("",""), ProprietaryBankTransactionCode = TransactionCodeJson("Transfer", "AlphaBank"), CardInstrument = CardInstrumentJson(), Balance =BalanceUKOpenBankingJson( - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse(""), - amount = transaction.balance + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(transaction.balance) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(transaction.balance), Type = "InterimBooked" ) ) @@ -385,12 +401,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { accountId, moderatedTransaction.id.value, TransactionReference = moderatedTransaction.description.getOrElse(""), - Amount = AmountOfMoneyJsonV121( - currency = moderatedTransaction.currency.getOrElse("") , - amount= moderatedTransaction.amount.getOrElse(BigDecimal(0)).toString()), + Amount = AmountUKOpenBankingJson( + Currency = moderatedTransaction.currency.getOrElse("") , + Amount = UKAmounts.unsignedAmount(moderatedTransaction.amount)), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(moderatedTransaction.amount), BookingDateTime = moderatedTransaction.startDate.get, ValueDateTime = moderatedTransaction.finishDate.get, - ChargeAmount = AmountOfMoneyJsonV121(moderatedTransaction.currency.getOrElse(""),"0"), + ChargeAmount = AmountUKOpenBankingJson("0", moderatedTransaction.currency.getOrElse("")), TransactionInformation = moderatedTransaction.description.getOrElse(""), CurrencyExchange = CurrencyExchangeJson( SourceCurrency = moderatedTransaction.bankAccount.map(_.currency).flatten.getOrElse(""), @@ -399,16 +416,16 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { ExchangeRate = 0, ContractIdentification = "string", QuotationDate = new Date(), - InstructedAmount = AmountOfMoneyJsonV121(moderatedTransaction.bankAccount.map(_.currency).flatten.getOrElse(""),"")), + InstructedAmount = AmountUKOpenBankingJson("", moderatedTransaction.bankAccount.map(_.currency).flatten.getOrElse(""))), BankTransactionCode = BankTransactionCodeJson("",""), ProprietaryBankTransactionCode = TransactionCodeJson("Transfer", "AlphaBank"), CardInstrument = CardInstrumentJson(), Balance =BalanceUKOpenBankingJson( - Amount = AmountOfMoneyJsonV121( - currency = moderatedTransaction.currency.getOrElse(""), - amount = moderatedTransaction.balance + Amount = AmountUKOpenBankingJson( + Currency = moderatedTransaction.currency.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(moderatedTransaction.balance) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedTransaction.balance), Type = "InterimBooked" ), MerchantDetails = getMerchantDetails(moderatedTransaction) @@ -436,13 +453,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { val dataJson = DataJsonUKV310( List(BalanceJsonUKV310( AccountId = accountId, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""), moderatedAccount.balance.getOrElse("")), - CreditDebitIndicator = "Credit", + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance.getOrElse("")), moderatedAccount.currency.getOrElse("")), + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedAccount.balance.getOrElse("")), Type = "ClosingAvailable", DateTime = null, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""),moderatedAccount.balance.getOrElse("")), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance.getOrElse("")), moderatedAccount.currency.getOrElse("")), Type = "Pre-Agreed" ))))) @@ -467,13 +484,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { val dataJson = DataJsonUKV310( accounts.map(account => BalanceJsonUKV310( AccountId = account.accountId.value, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), - CreditDebitIndicator = "Credit", + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(account.balance), Type = "ClosingAvailable", DateTime = account.lastUpdate, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), Type = "Available" ))))) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index fb1d20a7f9..949d31bc27 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.UKOpenBanking.{UKAmounts, UKTransactionsQuery} + import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.APIFailureNewStyle @@ -2269,28 +2271,11 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountIdStr / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => val accountId = AccountId(accountIdStr) - val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) - val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) - for { - _ <- NewStyle.function.checkUKConsent(u, Some(cc)) - _ <- passesPsd2Aisp(Some(cc)) - (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) - (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) - view <- ViewNewStyle.checkViewsAccessAndReturnView(detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) - params <- Future { - createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) - } map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (transactions, _) <- BankAccountExtended(account).getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), params) map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( - account.bankId, - transactions.map(_.id), - view.viewId, - Some(cc)) - } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(account.bankId, accountId.value, transactions, moderatedAttributes, view) + // The read itself is shared with v3.1 -- see UKTransactionsQuery. Only the factory differs. + UKTransactionsQuery.read(req, u, cc, accountId) map { result => + JSONFactory_UKOpenBanking_401.createTransactionsJsonNew( + result.account.bankId, accountId.value, result.transactions, result.attributes, result.view) + } } } resourceDocs += ResourceDoc( diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala index fb2fe5790e..4248923617 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.UKOpenBanking.UKAmounts + import java.util.Date import code.api.Constant @@ -114,7 +116,9 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { TransactionId: String, TransactionReference: Option[String] = None, StatementReference: List[String] = Nil, - CreditDebitIndicator: String = "Credit", + // No default: the direction has to be derived from the amount at every construction site + // (see UKAmounts). A default is how "Credit" ended up on every debit. + CreditDebitIndicator: String, Status: String = "BOOK", TransactionMutability: String = "Mutable", BookingDateTime: Date, @@ -220,13 +224,14 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { def createAccountBalanceJSON(moderatedAccount: ModeratedBankAccountCore): BalancesUKV401 = { val accountId = moderatedAccount.accountId.value + val rawBalance = moderatedAccount.balance.getOrElse("") val amount = AmountV401( - Amount = moderatedAccount.balance.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(rawBalance), Currency = moderatedAccount.currency.getOrElse("") ) val balance = BalanceV401( AccountId = accountId, - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(rawBalance), Type = "ClosingAvailable", DateTime = None, Amount = amount, @@ -241,10 +246,10 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { def createBalancesJSON(accounts: List[BankAccount]): BalancesUKV401 = { val balances = accounts.map { account => - val amount = AmountV401(Amount = account.balance.toString(), Currency = account.currency) + val amount = AmountV401(Amount = UKAmounts.unsignedAmount(account.balance), Currency = account.currency) BalanceV401( AccountId = account.accountId.value, - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicator(account.balance), Type = "ClosingAvailable", DateTime = Some(account.lastUpdate), Amount = amount, @@ -276,8 +281,9 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { case _ => None } + // UK keeps the sign out of Amount and in CreditDebitIndicator; OBP holds one signed number. val amount = AmountV401( - Amount = moderatedTransaction.amount.getOrElse(BigDecimal(0)).toString(), + Amount = UKAmounts.unsignedAmount(moderatedTransaction.amount), Currency = moderatedTransaction.currency.getOrElse("") ) TransactionV401( @@ -288,10 +294,13 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { BookingDateTime = moderatedTransaction.startDate.get, ValueDateTime = moderatedTransaction.finishDate.get, Amount = amount, + CreditDebitIndicator = UKAmounts.creditDebitIndicator(moderatedTransaction.amount), Balance = Some(TransactionBalanceV401( - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedTransaction.balance), Type = "InterimBooked", - Amount = AmountV401(Amount = moderatedTransaction.balance, Currency = moderatedTransaction.currency.getOrElse("")) + Amount = AmountV401( + Amount = UKAmounts.unsignedAmountString(moderatedTransaction.balance), + Currency = moderatedTransaction.currency.getOrElse("")) )), MerchantDetails = getMerchantDetails ) 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 f3d1084873..6fb92fb444 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 @@ -60,7 +60,12 @@ object Http4sBGv13AIS extends MdcLoggable { Future { Helper.booleanToBox(u.hasViewAccess(BankIdAccountId(account.bankId, account.accountId), viewId, callContext)) } map { - unboxFullOrFail(_, callContext, s"$NoViewReadAccountsBerlinGroup ${viewId.value} userId : ${u.userId}. account : ${account.accountId}", 403) + // No user id in the message. Under consent authentication `u` is the consent's own shadow + // user -- an internal identifier the TPP has no business learning and cannot act on. What + // the refusal is actually about is the view and the account, both of which the caller + // already named. The user id stays in the logs for anyone diagnosing it. + unboxFullOrFail(_, callContext, + s"$NoViewReadAccountsBerlinGroup ${viewId.value}. account : ${account.accountId}", 403) } } @@ -311,6 +316,17 @@ object Http4sBGv13AIS extends MdcLoggable { val callContext = Some(cc) for { _ <- passesPsd2Aisp(callContext) + // The same ownership test its sibling GET /consents/CONSENTID applies twelve lines below. + // Without it any PSD2-AISP caller could list the authorisation ids of a consent lodged by + // somebody else -- the PUT that answers one is guarded, so this leaked identifiers rather + // than access, but the asymmetry between two neighbouring reads of the same consent was an + // oversight, not a decision. + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)") + } + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) @@ -517,7 +533,7 @@ object Http4sBGv13AIS extends MdcLoggable { val cc = req.callContext val callContext = Some(cc) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) - if (checkTransactionAuthorisation(parsedJson)) { + if (startsAuthorisation(parsedJson)) { for { _ <- passesPsd2Aisp(callContext) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { @@ -528,7 +544,8 @@ object Http4sBGv13AIS extends MdcLoggable { // caller could raise a challenge on any consent id and then answer their own. _ <- Consent.checkBerlinGroupConsentAccess( consent.userId, consent.consumerId, - Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get)) match { + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get), + Consent.isScaFrontEnd(cc.consumer.map(_.consumerId.get))) match { case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) case None => Future.successful(true) } @@ -546,6 +563,12 @@ object Http4sBGv13AIS extends MdcLoggable { val failCode = if (reason == ConsentDoesNotMatchUser) 403 else 401 booleanToFuture(failMsg = reason, failCode = failCode, cc = callContext)(false).map(_ => "") } + // Refuse here rather than at the PUT, even though binding happens there: the next step + // sends this person an OTP out of band. A consent naming accounts they do not hold can + // never legitimately bind to them, so minting the challenge would only deliver a code to + // someone the TPP nominated for an authorisation that must fail. + (psuForCheck, callContext) <- NewStyle.function.findByUserId(psuUserId, callContext) + _ <- Consent.assertBerlinGroupConsentAccountsHeld(psuForCheck, consent, callContext) (challenges, callContext) <- NewStyle.function.createChallengesC2( List(psuUserId), ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, @@ -562,8 +585,12 @@ object Http4sBGv13AIS extends MdcLoggable { } yield { createStartConsentAuthorisationJson(consent, challenge) } - } else { - // mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + } else if (checkUpdatePsuAuthentication(parsedJson) || checkSelectPsuAuthenticationMethod(parsedJson)) { + // Mocked for the updatePsuAuthentication and selectPsuAuthenticationMethod variants, which + // are Embedded-approach steps OBP does not implement. Guarded now: this was the + // unconditional final else, so any body the server could not recognise was answered with + // this example -- a fabricated authorisationId, returned 201, that matches no challenge. + // The TPP only discovers it at the PUT, where the id resolves to nothing. Future.successful(com.openbankproject.commons.util.JsonAliases.parse( """{ "scaStatus": "received", @@ -574,6 +601,13 @@ object Http4sBGv13AIS extends MdcLoggable { "scaStatus": {"href":"/v1.3/consents/qwer3456tzui7890/authorisations/123auth456"} } }""")) + } else { + // None of the recognised shapes. A malformed request has to be reported as one; handing + // back an id that was never minted only moves the failure somewhere harder to read. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of " + + s"updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -594,7 +628,8 @@ object Http4sBGv13AIS extends MdcLoggable { // decides who a consent ends up belonging to. See Consent.checkBerlinGroupConsentAccess. _ <- Consent.checkBerlinGroupConsentAccess( storedConsent.userId, storedConsent.consumerId, - Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get)) match { + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get), + Consent.isScaFrontEnd(cc.consumer.map(_.consumerId.get))) match { case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) case None => Future.successful(true) } @@ -614,6 +649,12 @@ object Http4sBGv13AIS extends MdcLoggable { // the OTP was delivered to that person, so the challenge is the record of whose // authorisation this is -- the session is the TPP's and cannot say. (psu, callContext) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) + // The binding point, so the holdings check is repeated here rather than trusted from the + // POST: the two calls are separate requests and an account can change hands between + // them. It runs before validateChallengeAnswerC4 and before the status update, because + // none of what follows is transactional -- a refusal after any of it would leave the + // consent half-claimed. + _ <- Consent.assertBerlinGroupConsentAccountsHeld(psu, storedConsent, callContext) // Berlin Group Embedded has the TPP relay the PSU's OTP, so the identity the answer is // validated against is the challenge's PSU rather than the principal on the token. The // caller's own right to be here was settled by checkBerlinGroupConsentAccess above. @@ -672,8 +713,11 @@ object Http4sBGv13AIS extends MdcLoggable { | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} | } |}""".stripMargin)) - } else { - // authorisationConfirmation variant + } else if (checkAuthorisationConfirmation(parsedJson)) { + // authorisationConfirmation variant. Guarded by the checker that already existed for it: + // this was the unconditional final else, so a body matching none of the four shapes -- an + // empty one included -- was answered "scaStatus": "finalised", the terminal success state + // of strong customer authentication, for an authorisation nothing had happened to. Future.successful(com.openbankproject.commons.util.JsonAliases.parse( """{ | "scaStatus": "finalised", @@ -681,6 +725,13 @@ object Http4sBGv13AIS extends MdcLoggable { | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} | } |}""".stripMargin)) + } else { + // None of the four Berlin Group shapes. Malformed, and it has to say so: claiming an SCA + // outcome for a request the server could not read is worse than any of them. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be one of updatePsuAuthentication, " + + s"selectPsuAuthenticationMethod, transactionAuthorisation or authorisationConfirmation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } 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 d363d29a1a..d08ff72374 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 @@ -11,10 +11,12 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, getScaM import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats -import code.api.util.{ApiTag, CallContext, NewStyle} +import code.api.util.APIUtil.OBPReturnType +import code.api.util.{ApiTag, CallContext, Consent, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection} import code.fx.fx +import code.transactionrequests.TransactionRequests import code.util.Helper import code.util.Helper.{MdcLoggable, booleanToFuture} import com.github.dwickern.macros.NameOf.nameOf @@ -71,6 +73,46 @@ object Http4sBGv13PIS extends MdcLoggable { PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) }.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. + */ + 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)) + .map(_.mConsumerId.get).toOption.filter(_.nonEmpty) + sameTpp = lodgedByConsumer.forall(lodgedBy => callingConsumer.contains(lodgedBy)) + _ <- Helper.booleanToFuture(s"$PaymentNotInitiatedByCaller Payment id: $paymentId.", 403, callContext) { + sameTpp && initiators.exists(callers) + } + } yield (transactionRequest, callContext) + /** * Shared business logic for all three initiate-payment variants (payments / periodic-payments / * bulk-payments). Mirrors `initiatePaymentImplementation` from the Lift builder; auth is handled @@ -145,7 +187,7 @@ object Http4sBGv13PIS extends MdcLoggable { transactionRequestTypes <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { transactionRequest.body.to_sepa_credit_transfers.get } @@ -189,7 +231,7 @@ object Http4sBGv13PIS extends MdcLoggable { failMsg = s"$TransactionRequestCannotBeCancelled Payment status: $mappedStatus. Only payments in RCVD, ACCP, PDNG, or CANC status can be cancelled.", cc = callContext ) { canBeCancelled == true } - (updatedTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (updatedTransactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) } yield { startSca.getOrElse(false) match { case true => Some(createCancellationTransactionRequestJson(updatedTransactionRequest)) @@ -219,7 +261,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenge, _) <- NewStyle.function.getChallenge(cancellationId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challenge.scaStatus.map(_.toString).getOrElse("None")) @@ -240,7 +282,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { transactionRequest.body.to_sepa_credit_transfers.get } @@ -263,7 +305,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationsJson(challenges) @@ -284,6 +326,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.CancellationJsonV13(challenges.map(_.challengeId)) @@ -304,7 +347,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenge, _) <- NewStyle.function.getChallenge(authorisationId, callContext) } yield { json.parse(s"""{"scaStatus" : "${challenge.scaStatus.getOrElse("None")}"}""") @@ -326,7 +369,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) transactionRequestStatus = mapTransactionStatus(transactionRequest.status) transactionRequestAmount <- NewStyle.function.tryons(s"${InvalidNumber} transaction request amount cannot convert to a Decimal", 400, callContext) { BigDecimal(transactionRequest.body.to_sepa_credit_transfers.get.instructedAmount.amount) @@ -394,7 +437,7 @@ object Http4sBGv13PIS extends MdcLoggable { val callContext = Some(cc) val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) - if (checkTransactionAuthorisation(parsedJson)) { + if (startsAuthorisation(parsedJson)) { for { _ <- passesPsd2Pisp(callContext) _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { @@ -403,7 +446,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenges, _) <- NewStyle.function.createChallengesC2( List(u.userId), ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, @@ -420,8 +463,12 @@ object Http4sBGv13PIS extends MdcLoggable { } yield { JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) } - } else { - // Mocked response for updatePsuAuthentication and selectPsuAuthenticationMethod variants + } else if (checkUpdatePsuAuthentication(parsedJson) || checkSelectPsuAuthenticationMethod(parsedJson)) { + // Mocked for the updatePsuAuthentication and selectPsuAuthenticationMethod variants, which + // are Embedded-approach steps OBP does not implement. Guarded now: this was the + // unconditional final else, so any body the server could not recognise was answered with + // this example -- a fabricated authorisationId, returned 201, that matches no challenge. + // The TPP only discovers it at the PUT, where the id resolves to nothing. Future.successful(json.parse( """{ "challengeData": { @@ -433,6 +480,13 @@ object Http4sBGv13PIS extends MdcLoggable { } } }""")) + } else { + // None of the recognised shapes. A malformed request has to be reported as one; handing + // back an id that was never minted only moves the failure somewhere harder to read. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of " + + s"updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -445,7 +499,7 @@ object Http4sBGv13PIS extends MdcLoggable { val callContext = Some(cc) val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) - if (checkTransactionAuthorisation(parsedJson)) { + if (startsAuthorisation(parsedJson)) { for { _ <- passesPsd2Pisp(callContext) _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { @@ -454,7 +508,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) _ <- Helper.booleanToFuture(failMsg = CannotStartTheAuthorisationProcessForTheCancellation, cc = callContext) { transactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString } @@ -476,8 +530,12 @@ object Http4sBGv13PIS extends MdcLoggable { challenge, paymentService, paymentProduct, paymentId ) } - } else { - // Mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + } else if (checkUpdatePsuAuthentication(parsedJson) || checkSelectPsuAuthenticationMethod(parsedJson)) { + // Mocked for the updatePsuAuthentication and selectPsuAuthenticationMethod variants, which + // are Embedded-approach steps OBP does not implement. Guarded now: this was the + // unconditional final else, so any body the server could not recognise was answered with + // this example -- a fabricated authorisationId, returned 201, that matches no challenge. + // The TPP only discovers it at the PUT, where the id resolves to nothing. Future.successful(json.parse( """{ "scaStatus": "received", @@ -489,6 +547,13 @@ object Http4sBGv13PIS extends MdcLoggable { } } }""")) + } else { + // None of the recognised shapes. A malformed request has to be reported as one; handing + // back an id that was never minted only moves the failure somewhere harder to read. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of " + + s"updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -513,13 +578,13 @@ object Http4sBGv13PIS extends MdcLoggable { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } transactionRequestId = TransactionRequestId(paymentId) - (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + (existingTransactionRequest, _) <- getOwnPaymentImpl(transactionRequestId.value, callContext) _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUDataCancellation, cc = callContext) { existingTransactionRequest.status == TransactionRequestStatus.INITIATED.toString || existingTransactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString || existingTransactionRequest.status == TransactionRequestStatus.COMPLETED.toString } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, Some(paymentId), @@ -569,8 +634,11 @@ object Http4sBGv13PIS extends MdcLoggable { "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} } }""")) - } else { - // authorisationConfirmation variant + } else if (checkAuthorisationConfirmation(parsedJson)) { + // authorisationConfirmation variant. Guarded by the checker that already existed for it: + // this was the unconditional final else, so a body matching none of the four shapes -- an + // empty one included -- was answered "scaStatus": "finalised", the terminal success state + // of strong customer authentication, for an authorisation nothing had happened to. Future.successful(json.parse( """{ "scaStatus": "finalised", @@ -578,6 +646,13 @@ object Http4sBGv13PIS extends MdcLoggable { "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} } }""")) + } else { + // None of the four Berlin Group shapes. Malformed, and it has to say so: claiming an SCA + // outcome for a request the server could not read is worse than any of them. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be one of updatePsuAuthentication, " + + s"selectPsuAuthenticationMethod, transactionAuthorisation or authorisationConfirmation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -603,7 +678,7 @@ object Http4sBGv13PIS extends MdcLoggable { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } transactionRequestId = TransactionRequestId(paymentId) - (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + (existingTransactionRequest, _) <- getOwnPaymentImpl(transactionRequestId.value, callContext) _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUData, cc = callContext) { existingTransactionRequest.status == TransactionStatus.RCVD.code } @@ -657,8 +732,11 @@ object Http4sBGv13PIS extends MdcLoggable { "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} } }""")) - } else { - // authorisationConfirmation variant + } else if (checkAuthorisationConfirmation(parsedJson)) { + // authorisationConfirmation variant. Guarded by the checker that already existed for it: + // this was the unconditional final else, so a body matching none of the four shapes -- an + // empty one included -- was answered "scaStatus": "finalised", the terminal success state + // of strong customer authentication, for an authorisation nothing had happened to. Future.successful(json.parse( """{ "scaStatus": "finalised", @@ -666,6 +744,13 @@ object Http4sBGv13PIS extends MdcLoggable { "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} } }""")) + } else { + // None of the four Berlin Group shapes. Malformed, and it has to say so: claiming an SCA + // outcome for a request the server could not read is worse than any of them. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be one of updatePsuAuthentication, " + + s"selectPsuAuthenticationMethod, transactionAuthorisation or authorisationConfirmation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } 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 709b14a4ed..d35c6a6dc8 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 @@ -889,6 +889,27 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ JsonPost.extract[TransactionAuthorisation] }.isDefined + /** + * Whether this POST body means "start the authorisation process". + * + * Berlin Group hangs four request bodies off one path. An **empty body** is the plain start of an + * authorisation and the entry point of the Redirect approach; `updatePsuAuthentication` and + * `selectPsuAuthenticationMethod` are later Embedded-approach steps; `transactionAuthorisation` + * carries an SCA answer. + * + * The start handlers dispatch on the body's shape and only ever recognised the last of those, so + * an empty body fell to the same catch-all as the unimplemented variants and was answered with a + * hardcoded example -- HTTP 201 carrying the literal authorisationId "123auth456.". A TPP starting + * an authorisation the way the standard describes got a fabricated id back, and only found out at + * the PUT, where that id matched no challenge. It went unnoticed because OBP's own clients send + * `{"scaAuthenticationData": ""}`, so the one branch ever exercised was the one that worked. + * + * An empty body and a transactionAuthorisation body are the same request here: neither start + * handler reads scaAuthenticationData. The POST mints the challenge; the PUT twin answers it. + */ + def startsAuthorisation(JsonPost: JValue): Boolean = + JsonPost == JNothing || JsonPost == JObject(Nil) || checkTransactionAuthorisation(JsonPost) + def checkUpdatePsuAuthentication(JsonPost: JValue) = tryo { JsonPost.extract[UpdatePsuAuthentication] }.isDefined diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 6cf2b82a50..486cee3f02 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -169,6 +169,11 @@ object Constant extends MdcLoggable { final val SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID = "ReadTransactionsBerlinGroup" final val SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID = "InitiatePaymentsBerlinGroup" + // A VRP mandate gets its own private custom view, named after the mandate. The prefix is how the + // consent conversion recognises a VRP consent, and how revocation finds the artefacts to release, + // so both ends read it from here rather than repeating the literal. + final val VRP_VIEW_ID_PREFIX = "_vrp-" + //This is used for the canRevokeAccessToViews_ and canGrantAccessToViews_ fields of SYSTEM_OWNER_VIEW_ID or SYSTEM_STANDARD_VIEW_ID. final val DEFAULT_CAN_GRANT_AND_REVOKE_ACCESS_TO_VIEWS = SYSTEM_OWNER_VIEW_ID:: 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 50c383f6b4..a6d50d26fb 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -4744,6 +4744,22 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ APIUtil.getPropsValue("email_domain_to_space_mappings").map(extractor).getOrElse(Nil) } + /** + * The Consumers the ASPSP declares to be its own SCA front end. + * + * Read from `sca_front_end_consumer_ids`. The Berlin-Group-specific key this started life as is + * still honoured, so an instance already configured for the Berlin Group redirect flow keeps + * working without being edited. + * + * Empty by default, which is what makes every use of it a no-op unless an ASPSP opts in. + */ + val scaFrontEndConsumerIds: List[String] = { + def read(key: String) = APIUtil.getPropsValue(key) + .map(_.split(",").toList.map(_.trim).filter(_.nonEmpty)) + .getOrElse(Nil) + (read("sca_front_end_consumer_ids") ++ read("berlin_group_sca_front_end_consumer_ids")).distinct + } + val skipConsentScaForConsumerIdPairs: List[ConsumerIdPair] = { def extractor(str: String) = try { val consumerIdPair = json.parse(str).extract[List[ConsumerIdPair]] diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala index 95706d8f3a..c00d16c945 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -196,18 +196,34 @@ object BerlinGroupCheck extends MdcLoggable { .getOrElse(forwardResult) } + /** + * Whether the PSU was behind this request, which is what `frequencyPerDay` counts: it is "the + * requested maximum frequency for an access without PSU involvement per day". + * + * NextGenPSD2 settles the question with one header. On every AIS read and consent-management call, + * `PSU-IP-Address` "shall be contained if and only if this request was actively initiated by the + * PSU" (psd2-api v1.3, parameter `PSU-IP-Address_conditionalForAis`). Omitting it is therefore the + * TPP's declaration that no PSU is involved, and that is the case the daily limit governs. + * + * This used to be read the other way round: only a request carrying a sentinel value counted, so a + * TPP that simply sent nothing — the very shape the spec reserves for unattended access — was never + * counted at all, and each TPP decided whether its own daily limit applied to it. + * + * The two sentinels are still honoured, for a TPP that sends the header unconditionally and marks + * the no-PSU case in the value rather than by omission. + */ def isTppRequestsWithoutPsuInvolvement(requestHeaders: List[HTTPParam]): Boolean = { - val psuIpAddress = getHeaderValue(RequestHeader.`PSU-IP-Address`, requestHeaders) - val psuDeviceId = getHeaderValue(RequestHeader.`PSU-Device-ID`, requestHeaders) - val psuDeviceNAme = getHeaderValue(RequestHeader.`PSU-Device-Name`, requestHeaders) - if(psuIpAddress == "0.0.0.0" || psuDeviceId == "no-psu-involved" || psuDeviceNAme == "no-psu-involved") { - logger.debug(s"isTppRequestsWithoutPsuInvolvement.psuIpAddress: $psuIpAddress") - logger.debug(s"isTppRequestsWithoutPsuInvolvement.psuDeviceId: $psuDeviceId") - logger.debug(s"isTppRequestsWithoutPsuInvolvement.psuDeviceNAme: $psuDeviceNAme") - true - } else { - false - } + def valueOf(name: String): Option[String] = + requestHeaders.find(_.name.equalsIgnoreCase(name)).map(_.values.mkString.trim).filter(_.nonEmpty) + + val psuIpAddress = valueOf(RequestHeader.`PSU-IP-Address`) + val markedAsUnattended = psuIpAddress.contains("0.0.0.0") || + valueOf(RequestHeader.`PSU-Device-ID`).contains("no-psu-involved") || + valueOf(RequestHeader.`PSU-Device-Name`).contains("no-psu-involved") + + val withoutPsu = psuIpAddress.isEmpty || markedAsUnattended + logger.debug(s"isTppRequestsWithoutPsuInvolvement: $withoutPsu (PSU-IP-Address: $psuIpAddress)") + withoutPsu } def validate(body: Box[String], verb: String, url: String, reqHeaders: List[HTTPParam], forwardResult: (Box[User], Option[CallContext])): OBPReturnType[Box[User]] = { 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 6847cd36cb..711c20cc0a 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -24,6 +24,8 @@ import code.scheduler.ConsentScheduler.currentDate import code.users.Users import code.util.Helper import code.util.Helper.MdcLoggable +import code.counterpartylimit.CounterpartyLimitProvider +import code.metadata.counterparties.Counterparties import code.views.Views import com.nimbusds.jwt.JWTClaimsSet import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -39,7 +41,10 @@ import net.liftweb.util.Props import java.text.SimpleDateFormat import java.util.Date import scala.collection.immutable.{List, Nil} -import scala.concurrent.Future +import scala.concurrent.{Await, Future} +import scala.concurrent.duration._ +// Not scala.util.Failure: net.liftweb.common._ above already binds that name to Box's failure case. +import scala.util.{Success, Try} // Design boundary (not enforced by the compiler — keep it that way by convention): consent-layer // attributes belong on the consent record, never as a View can_* permission. BG's @@ -677,8 +682,24 @@ object Consent extends MdcLoggable { } } + /** + * Whether this pass through the authentication pipeline is the one that will actually serve the + * request, and so the one that should spend a frequencyPerDay access. + * + * The pipeline runs many times per HTTP request: each API version wraps its own routes in its + * own ResourceDocMiddleware, and a middleware whose index holds no matching doc still runs + * best-effort authentication before falling through to the next one + * (ResourceDocMiddleware.scala, the `case None` branch). Only the middleware that matched a + * ResourceDoc attaches it to the CallContext, so its presence is exactly the distinction. + * + * Without this, one request spent an access per middleware in the chain, which drove the + * counter to frequencyPerDay before the request was served: a consent asking for four accesses + * a day got none, answering 429 to its very first call. + */ + def isTheServingPass: Boolean = callContext.resourceDocument.isDefined + def checkFrequencyPerDay(storedConsent: consent.ConsentTrait) = { - if(BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { + if(isTheServingPass && BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { def isSameDay(date1: Date, date2: Date): Boolean = { val fmt = new SimpleDateFormat("yyyyMMdd") fmt.format(date1).equals(fmt.format(date2)) @@ -727,7 +748,7 @@ object Consent extends MdcLoggable { logger.debug(s"End of com.openbankproject.commons.util.JsonAliases.parse(jsonAsString).extract[ConsentJWT].checkConsent.consentBox: $consent") consentBox match { // Check is it Consent-JWT expired case (Full(true)) => // OK - if(BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { + if(isTheServingPass && BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { // Update MappedConsent.usesSoFarTodayCounter field val consentUpdatedBox = Consents.consentProvider.vend.updateBerlinGroupConsent(consentId, currentCounterState + 1) logger.debug(s"applyBerlinGroupConsentRulesCommon.consentUpdatedBox: $consentUpdatedBox") @@ -869,8 +890,10 @@ object Consent extends MdcLoggable { */ def revokeConsentAccountAccess(consent: code.consent.ConsentTrait): Unit = { implicit val dateFormats = CustomJsonFormats.formats + val consentJwtBox = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map(parse(_).extract[ConsentJWT]) + val revoked = for { - consentJwt <- JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map(parse(_).extract[ConsentJWT]) + consentJwt <- consentJwtBox shadowUser <- Users.users.vend.getUserByProviderId(provider = consentJwt.iss, idGivenByProvider = consentJwt.sub) } yield { Views.views.vend.accessGrantedToUserForConsumer(shadowUser, Constant.ALL_CONSUMERS).map { access => @@ -882,8 +905,89 @@ object Consent extends MdcLoggable { logger.info(s"revokeConsentAccountAccess: dropped $count account access rows for consent ${consent.consentId}") case _ => } + + // Deliberately outside the comprehension above, and after it. Outside, because a VRP consent + // that never reached SCA has no shadow user, and its mandate still has to be released -- an + // abandoned mandate is exactly the case that used to accumulate. After, because the view can + // only be removed once every access row pointing at it is gone, the shadow user's included. + consentJwtBox.foreach(releaseVrpMandateArtefacts(consent, _)) } + /** + * Release the artefacts a VRP mandate created, when the consent that owns them is revoked. + * + * Converting a VRP consent-request builds a private custom view named `_vrp-`, grants it to + * the PSU, hangs a counterparty off it and gives that counterparty a limit. Together they are the + * mandate: the view carries CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, and the limit is how much + * may be paid under it. Revoking the consent used to drop only the shadow user's access, so the + * PSU kept a live standing payment authority for a mandate they had just cancelled -- and one set + * of these accumulated on the account for every mandate ever requested, revoked or abandoned. + * + * Each artefact is named after the view, and the view belongs to exactly one consent, so this can + * be undone without guessing. What gets released, and what deliberately does not: + * + * - the PSU's grant on the view, which is the authority itself; + * - the counterparty's limit, which is the amount that authority was good for; + * - the view, but only once no access row is left pointing at it -- removeCustomView refuses + * otherwise, so a view some other principal still holds is left alone rather than orphaning it. + * + * The counterparty row stays. It is a payee record that settled transactions refer to, and + * deleting it would take history with it; with the view and the limit gone it grants nothing. + */ + private def releaseVrpMandateArtefacts(consent: code.consent.ConsentTrait, consentJwt: ConsentJWT): Unit = { + val vrpViews = consentJwt.views.filter(_.view_id.startsWith(Constant.VRP_VIEW_ID_PREFIX)) + if (vrpViews.nonEmpty) { + Users.users.vend.getUserByUserId(consent.userId) match { + case Full(psu) => + vrpViews.foreach { consentView => + val bankId = BankId(consentView.bank_id) + val accountId = AccountId(consentView.account_id) + val viewId = ViewId(consentView.view_id) + + Views.views.vend.revokeAccessToViewForUserAndConsumer( + BankIdAccountIdViewId(bankId, accountId, viewId), psu, Constant.ALL_CONSUMERS) + + // A Failure here is not "this view has no counterparties" -- treating it as such would + // skip the deletions and still report success below. + Counterparties.counterparties.vend.getCounterparties(bankId, accountId, viewId) match { + case Full(counterparties) => + counterparties.foreach { counterparty => + // Awaited so the deletion's outcome is observed rather than left in an unwatched + // Future -- but caught, because the caller has already committed the revoke. The + // status flip and the shadow user's access are gone by the time this runs, and a + // second attempt is refused with ConsentAlreadyRevoked, so letting a timeout or a + // connector failure escape would turn a completed revoke into a 500 and abandon + // the views still queued behind this one. + Try(Await.result( + CounterpartyLimitProvider.counterpartyLimit.vend.deleteCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterparty.counterpartyId), + 10.seconds)) match { + case Success(deleted) if deleted.isDefined => // released + case other => logger.warn( + s"releaseVrpMandateArtefacts: could not delete the limit on ${viewId.value} " + + s"for counterparty ${counterparty.counterpartyId}: $other") + } + } + case other => + logger.warn(s"releaseVrpMandateArtefacts: could not list counterparties on " + + s"${viewId.value} for consent ${consent.consentId}, limits left in place: $other") + } + + Views.views.vend.removeCustomView(viewId, BankIdAccountId(bankId, accountId)) match { + case Full(_) => + logger.info(s"releaseVrpMandateArtefacts: released ${viewId.value} for consent ${consent.consentId}") + case other => + // Something still holds the view. Its authority is gone either way; say so and stop. + logger.info(s"releaseVrpMandateArtefacts: kept ${viewId.value} for consent ${consent.consentId}: $other") + } + } + case _ => + logger.warn(s"releaseVrpMandateArtefacts: no PSU on consent ${consent.consentId}, mandate views left in place") + } + } + } + + /** * The Bearer-token half of the shadow-user resolution. * @@ -1425,9 +1529,22 @@ object Consent extends MdcLoggable { consentUserId: String, consentConsumerId: String, callerUserId: Option[String], - callerConsumerId: Option[String] - ): Option[String] = - psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + callerConsumerId: Option[String], + callerIsScaFrontEnd: Boolean + ): Option[String] = { + val stillUnclaimed = Option(consentUserId).map(_.trim).forall(_.isEmpty) + // The ASPSP's own approval screen has to read the consent to show the PSU what they are being + // asked to grant, and it arrives under its own Consumer rather than the TPP's -- so the lodging + // Consumer comparison refuses precisely the caller whose whole job is to inform the PSU, and the + // screen renders with no permissions, no status and no expiry. + // + // Narrow on purpose. It applies only while the consent is still unclaimed, which is the only + // window the approval screen exists for; once a PSU is bound, the PSU half below governs and a + // declared front end gets no further than anyone else. And it is inert unless an ASPSP has + // declared a front end at all. + if (callerIsScaFrontEnd && stillUnclaimed) None + else psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + } /** * Decide whether a caller may drive a Berlin Group consent's authorisation sub-resources -- @@ -1460,14 +1577,55 @@ object Consent extends MdcLoggable { * marks these calls Client Credentials, so no PSU is party to them. Berlin Group's rests on the * blanket same-TPP rule above plus PSU binding happening at SCA time. Different premises, same * conclusion, so they share one implementation rather than one being copied onto the other. + * + * With one exception, and it is in the wording of the rule itself: it binds "methods submitted by + * a TPP". Under the Redirect approach the PSU authenticates at the ASPSP, so these calls arrive + * from the ASPSP's own front end -- not a TPP, and never the Consumer that lodged the consent. + * Applying the rule there refuses the only caller Redirect has, and the scaRedirect ceremony + * cannot complete at all. + * + * Nothing in the request tells that front end apart from a second TPP holding a PSU session -- + * both are an authenticated person arriving under a Consumer that did not lodge the consent -- so + * the ASPSP declares its own, and callerIsScaFrontEnd is that declaration reaching this rule. See + * APIUtil.scaFrontEndConsumerIds. It is not a way past the PSU half: a consent already + * bound to someone still only re-binds to them. + * + * What it is emphatically not is a substitute for checking that the claiming PSU has anything to + * do with the accounts the consent names. A Consumer comparison could never have provided that -- + * the TPP that lodged the consent passes it by definition, and is the party the access accrues to + * -- so that check lives on its own in assertBerlinGroupConsentAccountsHeld, which the + * authorisation pair applies before anything is written. */ def checkBerlinGroupConsentAccess( consentUserId: String, consentConsumerId: String, callerUserId: Option[String], - callerConsumerId: Option[String] - ): Option[String] = - psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + callerConsumerId: Option[String], + callerIsScaFrontEnd: Boolean + ): Option[String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + (present(consentUserId), callerUserId.flatMap(present)) match { + case (Some(psu), Some(caller)) if psu != caller => Some(ErrorMessages.ConsentDoesNotMatchUser) + case (Some(_), Some(_)) => None + // Only while the consent is still unclaimed. A caller with no PSU used to reach this too, so a + // declared front end presenting client credentials could drive the authorisation of a consent + // already bound to somebody else -- the opposite of what the paragraph above promises. + case (None, _) if callerIsScaFrontEnd => None + case _ => psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + } + } + + /** + * Whether the Consumer making this call is one the ASPSP declared as its own SCA front end. + * + * Not Berlin-Group-specific: the same front end drives the UK approval screen, and the same + * difficulty applies there -- nothing in the request distinguishes the ASPSP's own screen from a + * second TPP holding a PSU session, so the ASPSP has to say which Consumer is its own. + */ + def isScaFrontEnd(callerConsumerId: Option[String]): Boolean = + callerConsumerId.map(_.trim).filter(_.nonEmpty) + .exists(APIUtil.scaFrontEndConsumerIds.contains) /** * The rule shared by checkUKConsentAccess and checkBerlinGroupConsentAccess: a consent bound to a @@ -1553,6 +1711,55 @@ object Consent extends MdcLoggable { def genuinePsu(callContext: CallContext): Option[User] = callContext.user.toOption.filterNot(u => callContext.consumer.map(_.key.get).contains(u.idGivenByProvider)) + /** + * Refuse a Berlin Group consent authorisation unless the PSU claiming it holds every account the + * consent names, returning the reason to refuse or None. + * + * A Berlin Group consent carries its accounts from the moment it is created: the TPP lists IBANs + * in the access object and createBerlinGroupConsentJWT resolves each one to a (bank_id, + * account_id) view before any PSU is involved. Nothing until now checked that the PSU who + * eventually authorises it has anything to do with those accounts, and the consequence was + * reachable rather than theoretical -- a consent naming another customer's IBAN, authorised by a + * PSU who does not hold it, bound and then served that account's details and balances to the TPP. + * It is the same gap UK closed at its own authorise step, and the same error answers it. + * + * Read off the JWT rather than the access object because the JWT is what the read path will + * actually grant: applyConsentRules materialises exactly these views for the consent's shadow + * user. Checking anything else would leave the two able to disagree. + * + * A consent whose JWT names no account yet is not refused here. That is the availableAccounts + * ("allAccounts") shape, whose views are materialised later and against the PSU's own holdings, + * so there is nothing for this check to compare and nothing it could wrongly let through. + */ + def assertBerlinGroupConsentAccountsHeld( + psu: User, + storedConsent: consent.ConsentTrait, + callContext: Option[CallContext] + ): Future[Box[Unit]] = Future { + implicit val dateFormats: Formats = CustomJsonFormats.formats + val consentAccounts: List[(String, String)] = JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken) + .map(com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT]) + .map(_.views.map(v => (v.bank_id, v.account_id)).distinct) + .getOrElse(Nil) + .filter { case (bankId, accountId) => bankId != null && accountId != null } + + val notHeld: List[String] = consentAccounts + .groupBy(_._1) + .toList + .flatMap { case (bankId, pairs) => + val held = AccountHolders.accountHolders.vend + .getAccountsHeld(BankId(bankId), psu) + .map(_.accountId.value) + pairs.map(_._2).filterNot(held.contains) + } + + (notHeld.isEmpty, notHeld): (Boolean, List[String]) + } flatMap { case (allHeld: Boolean, notHeld: List[String]) => + Helper.booleanToFuture( + s"${ErrorMessages.ConsentAccountNotHeldByUser} Account(s): ${notHeld.mkString(", ")}", + 403, callContext)(allHeld) + } + /** * The PSU a caller is acting as, or None when it is acting only as itself. * @@ -1588,7 +1795,8 @@ object Consent extends MdcLoggable { ): Future[Box[Unit]] = { val refusal = checkUKConsentAccess( consentUserId, consentConsumerId, - actingPsu(callContext).map(_.userId), callContext.consumer.map(_.consumerId.get)) + actingPsu(callContext).map(_.userId), callContext.consumer.map(_.consumerId.get), + isScaFrontEnd(callContext.consumer.map(_.consumerId.get))) // booleanToFuture only reads failMsg when the statement is false, so the empty default is never // the message anyone sees. Helper.booleanToFuture(refusal.getOrElse(""), 403, Some(callContext))(refusal.isEmpty) @@ -1904,9 +2112,19 @@ object Consent extends MdcLoggable { // nothing left to re-derive from a token that does not exist. if (calContext.flatMap(_.ukConsentId).isDefined) return Full(true) + // No Authorization header, and applyUKRules did not run either. The usual way to arrive here + // is a consent of another standard: the dispatcher routes it into that standard's branch, + // which authenticates the request and leaves ukConsentId unset, and then a UK endpoint asks + // this question. That is an ordinary refusal and the mirror of what the Berlin Group side + // answers for a UK consent, so it gets the same code. Throwing instead surfaced it as + // OBP-50000 Unknown Error at 500 -- a server fault for a request that was merely not + // entitled. val accessToken = calContext.flatMap(_.authReqHeaderField) - .map(_.replaceFirst("Bearer\\s+", "")) - .getOrElse(throw new RuntimeException("Not found http request header 'Authorization', it is mandatory.")) + .map(_.replaceFirst("Bearer\\s+", "")) match { + case Some(token) => token + case None => + return Failure(s"$ConsentDoesNotMatchStandard Required: $ConsentStandardUK.") + } val boxedConsent: Box[MappedConsent] = JwtUtil.getOptionalClaim("consent_id", accessToken) match { case Some(consentId) => Consents.consentProvider.vend.getConsentByConsentId(consentId) 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 4c9497691e..92f94822b8 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -896,6 +896,7 @@ object ErrorMessages { val OpenCorridorPublishFailed = "OBP-40055: Could not publish the Open Corridor message to the bank's broker or no reply arrived in time." val OpenCorridorSettlementAddressMissing = "OBP-40056: The creditor bank has no settlement address registered in its Open Corridor broker registration, so the settlement instruction cannot be addressed." val OpenCorridorDisabled = "OBP-40057: Open Corridor is not enabled on this API instance. Set open_corridor_enabled=true in the props." + val PaymentNotInitiatedByCaller = "OBP-40058: The addressed payment was not initiated by you. " // Exceptions (OBP-50XXX) val UnknownError = "OBP-50000: Unknown Error." val FutureTimeoutException = "OBP-50001: Future Timeout Exception." diff --git a/obp-api/src/main/scala/code/api/util/OBPParam.scala b/obp-api/src/main/scala/code/api/util/OBPParam.scala index 6afeabfd7f..4067525668 100644 --- a/obp-api/src/main/scala/code/api/util/OBPParam.scala +++ b/obp-api/src/main/scala/code/api/util/OBPParam.scala @@ -24,6 +24,43 @@ case class OBPOffset(value: Int) extends OBPQueryParam case class OBPFromDate(value: Date) extends OBPQueryParam case class OBPToDate(value: Date) extends OBPQueryParam case class OBPOrdering(field: Option[String], order: OBPOrder) extends OBPQueryParam +/** + * Restrict a transaction query to one direction, so the database applies the restriction and any + * page limit together. + * + * `credits = true` keeps money in, `false` keeps money out. Zero counts as a credit, which is what + * UK Open Banking states and what UKAmounts.creditDebitIndicator implements -- the two have to + * agree, or a row could be labelled one direction in the response and selected as the other. + * + * A connector that does not honour this returns more rows than asked for, never fewer, so callers + * that use it to enforce a consent's scope must still filter what comes back. + */ +case class OBPTransactionDirection(credits: Boolean) extends OBPQueryParam +object OBPTransactionDirection { + + /** + * Where a credit starts, in the smallest currency unit. Inclusive, because zero is a credit. + * + * Lives beside the param because two enforcements have to agree on it: the connector's SQL + * predicate and the endpoint's filter over the rows that came back. A connector holding its own + * literal could drift from the filter, and a row would then be selected by one and dropped by the + * other. + */ + val creditFloorInSmallestUnit = 0L + + /** + * Whether the restriction this param expresses keeps a row of the given amount. + * + * The in-memory statement of what the connector's query does, so a test can hold the two sides of + * the rule against each other without standing up a database. Not a substitute for reading real + * rows through the real query -- uk_direction_paging.py does that -- but it pins the boundary. + */ + def admits(param: OBPQueryParam, amountInSmallestUnit: Long): Boolean = param match { + case OBPTransactionDirection(true) => amountInSmallestUnit >= creditFloorInSmallestUnit + case OBPTransactionDirection(false) => amountInSmallestUnit < creditFloorInSmallestUnit + case _ => true + } +} case class OBPConsumerId(value: String) extends OBPQueryParam case class OBPSortBy(value: String) extends OBPQueryParam case class OBPAzp(value: String) extends OBPQueryParam diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 40fcd87a1e..3a2f20d3e7 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -962,7 +962,7 @@ object Http4s500 { val isVrpConsent = (viewsFromJwtToken.length == 1) && viewsFromJwtToken.head.bank_id.nonEmpty && viewsFromJwtToken.head.account_id.nonEmpty && - viewsFromJwtToken.head.view_id.startsWith("_vrp-") + viewsFromJwtToken.head.view_id.startsWith(VRP_VIEW_ID_PREFIX) if (isVrpConsent) { val bId = BankId(viewsFromJwtToken.head.bank_id) val aId = AccountId(viewsFromJwtToken.head.account_id) @@ -1083,7 +1083,7 @@ object Http4s500 { } (bankId, accountId, viewId, counterpartyId) <- if (isVRPConsentRequest) { val postConsentRequestJsonV510 = json.parse(createdConsentRequest.payload).extract[code.api.v5_1_0.PostVRPConsentRequestJsonV510] - val vrpViewId = s"_vrp-${UUID.randomUUID.toString}".dropRight(5) + val vrpViewId = s"${VRP_VIEW_ID_PREFIX}${UUID.randomUUID.toString}".dropRight(5) val targetPermissions = List( CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GET_COUNTERPARTY, diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 045db59c5f..70d4ad7a49 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -64,7 +64,7 @@ import com.openbankproject.commons.model.{ } import com.openbankproject.commons.model.enums.{AtmAttributeType, ChallengeType, ConsentType, RegulatedEntityAttributeType, StrongCustomerAuthentication, StrongCustomerAuthenticationStatus, SuppliedAnswerType, TransactionRequestStatus, UserAttributeType} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} -import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} import com.openbankproject.commons.util.json import com.openbankproject.commons.util.JsonAliases.prettyRender import org.json4s.{Extraction, Formats} @@ -4422,8 +4422,23 @@ object Http4s510 { // consent id travels to the browser in the authorisation redirect, so a single failing // request from anyone who had seen one was enough. Nothing here is transactional, so // ordering is what has to carry it: refuse first, commit afterwards. + // + // Not connectorEmptyResponse: that turns every Box into InvalidConnectorResponse at + // 400, so the refusal reached the TPP as "OBP-50200 Connector cannot return the data + // we requested. connectorEmptyResponse <- OBP-35037 ..." -- an authorisation decision + // presented as a connector fault, with the reason buried behind a cause it has + // nothing to do with. A Failure here carries its own message and is the same kind of + // answer as the ConsentDoesNotMatchUser guard above, so it gets the same 403. Only a + // genuinely empty Box is a connector problem. _ <- Consent.grantUKConsentAccountAccess(user, BankId(bankIdStr), authJson.account_ids, consent, Some(cc)) - .map(i => connectorEmptyResponse(i, Some(cc))) + .flatMap { + case Full(granted) => Future.successful(granted) + case Failure(reason, _, _) => + // booleanToFuture(false) always fails, so the mapped value is never reached -- + // it only lines the branches up to one type. + Helper.booleanToFuture(reason, 403, Some(cc))(false).map(_ => consent) + case Empty => Future.successful(connectorEmptyResponse(Empty: Box[MappedConsent], Some(cc))) + } // Bind the PSU as the consent's user in the DB (mUserId). consentAfterBind <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, user)) .map(i => connectorEmptyResponse(i, Some(cc))) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 0b6531fdbc..b0d0c2aa50 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -712,13 +712,28 @@ object LocalMappedConnector extends Connector with MdcLoggable { .map(transaction => (transaction, callContext)) } - override def getTransactionsLegacy(bankId: BankId, accountId: AccountId, callContext: Option[CallContext], queryParams: List[OBPQueryParam]) = { - - // TODO Refactor this. No need for database lookups etc. + /** + * The OBPQueryParams a transaction read carries, as Mapper query params. + * + * One copy for both transaction reads below. They had identical translations, and the direction + * restriction had to be written into each of them -- a rule that decides what a consent may see + * should exist once, not once per caller that remembers to add it. + * + * The direction restriction is pushed into the query rather than applied to the rows afterwards, + * so the database narrows and paginates in the same pass; filtering an already-limited page hands + * the caller a short page it cannot distinguish from the end of the data. Zero counts as a credit, + * matching UKAmounts.creditDebitIndicator -- `amount` is signed and in the smallest currency unit, + * so its sign is all this needs. + */ + private def transactionQueryParams(queryParams: List[OBPQueryParam]): Seq[QueryParam[MappedTransaction]] = { val limit = queryParams.collect { case OBPLimit(value) => MaxRows[MappedTransaction](value) }.headOption val offset = queryParams.collect { case OBPOffset(value) => StartAt[MappedTransaction](value) }.headOption val fromDate = queryParams.collect { case OBPFromDate(date) => By_>=(MappedTransaction.tFinishDate, date) }.headOption val toDate = queryParams.collect { case OBPToDate(date) => By_<=(MappedTransaction.tFinishDate, date) }.headOption + val direction = queryParams.collect { + case OBPTransactionDirection(true) => By_>=(MappedTransaction.amount, OBPTransactionDirection.creditFloorInSmallestUnit) + case OBPTransactionDirection(false) => By_<(MappedTransaction.amount, OBPTransactionDirection.creditFloorInSmallestUnit) + }.headOption val ordering = queryParams.collect { //we don't care about the intended sort field and only sort on finish date for now case OBPOrdering(_, direction) => @@ -727,8 +742,13 @@ object LocalMappedConnector extends Connector with MdcLoggable { case OBPDescending => OrderBy(MappedTransaction.tFinishDate, Descending) } } + Seq(limit.toSeq, offset.toSeq, fromDate.toSeq, toDate.toSeq, direction.toSeq, ordering.toSeq).flatten + } + + override def getTransactionsLegacy(bankId: BankId, accountId: AccountId, callContext: Option[CallContext], queryParams: List[OBPQueryParam]) = { - val optionalParams: Seq[QueryParam[MappedTransaction]] = Seq(limit.toSeq, offset.toSeq, fromDate.toSeq, toDate.toSeq, ordering.toSeq).flatten + // TODO Refactor this. No need for database lookups etc. + val optionalParams: Seq[QueryParam[MappedTransaction]] = transactionQueryParams(queryParams) val mapperParams = Seq(By(MappedTransaction.bank, bankId.value), By(MappedTransaction.account, accountId.value)) ++ optionalParams def getTransactionsCached(bankId: BankId, accountId: AccountId, optionalParams: Seq[QueryParam[MappedTransaction]]): Box[List[Transaction]] @@ -761,20 +781,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getTransactionsCore(bankId: BankId, accountId: AccountId, queryParams: List[OBPQueryParam], callContext: Option[CallContext]): OBPReturnType[Box[List[TransactionCore]]] = { // TODO Refactor this. No need for database lookups etc. - val limit = queryParams.collect { case OBPLimit(value) => MaxRows[MappedTransaction](value) }.headOption - val offset = queryParams.collect { case OBPOffset(value) => StartAt[MappedTransaction](value) }.headOption - val fromDate = queryParams.collect { case OBPFromDate(date) => By_>=(MappedTransaction.tFinishDate, date) }.headOption - val toDate = queryParams.collect { case OBPToDate(date) => By_<=(MappedTransaction.tFinishDate, date) }.headOption - val ordering = queryParams.collect { - //we don't care about the intended sort field and only sort on finish date for now - case OBPOrdering(_, direction) => - direction match { - case OBPAscending => OrderBy(MappedTransaction.tFinishDate, Ascending) - case OBPDescending => OrderBy(MappedTransaction.tFinishDate, Descending) - } - } - - val optionalParams: Seq[QueryParam[MappedTransaction]] = Seq(limit.toSeq, offset.toSeq, fromDate.toSeq, toDate.toSeq, ordering.toSeq).flatten + val optionalParams: Seq[QueryParam[MappedTransaction]] = transactionQueryParams(queryParams) val mapperParams = Seq(By(MappedTransaction.bank, bankId.value), By(MappedTransaction.account, accountId.value)) ++ optionalParams def getTransactionsCached(bankId: BankId, accountId: AccountId, optionalParams: Seq[QueryParam[MappedTransaction]]): Box[List[TransactionCore]] @@ -879,23 +886,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getBankAccountByRoutingLegacy(bankId: Option[BankId], scheme: String, address: String, callContext: Option[CallContext]): Box[(BankAccount, Option[CallContext])] = { - // OBP-family schemes (OBP / OBP_ACCOUNT_ID) are implicit self-identifiers - // — address IS the account_id. Resolve directly against the BankAccount - // table without touching BankAccountRouting. - if (isImplicitOBPAccountScheme(scheme)) { - bankId match { - case Some(bankId) => - getBankAccountCommon(bankId, AccountId(address), callContext) - case None => - // No bank context — accept only when the account_id is globally unique. - MappedBankAccount.findAll(By(MappedBankAccount.theAccountId, address)) match { - case account :: Nil => Full((account, callContext)) - case Nil => Empty - case _ => - Failure(s"$AccountRoutingNotUnique (scheme: $scheme, address: $address)") - } - } - } else { + def byRoutingTable: Box[(MappedBankAccount, Option[CallContext])] = { def handleRouting(routing: List[BankAccountRouting]): Box[(MappedBankAccount, Option[CallContext])] = { if (routing.size > 1) { // Handle more than 1 occurrence // Routing MUST be unique @@ -917,6 +908,43 @@ object LocalMappedConnector extends Connector with MdcLoggable { handleRouting(routing) } } + + // OBP-family schemes (OBP / OBP_ACCOUNT_ID) are implicit self-identifiers — address IS the + // account_id — so they resolve directly against the BankAccount table. + // + // But that is not the only thing an OBP-scheme address can be. A bank may also *register* an + // `OBP` routing whose address is something other than the account id, and BankAccountRouting + // stores it like any other. Treating the implicit reading as the only one made those accounts + // unreachable through every endpoint that resolves by routing: the account was right there in + // the table, and the answer was "Bank Account not found". + // + // So try the implicit reading first, and fall back to the registered routing when it finds + // nothing. The implicit reading still wins where both would match, which is what happened + // before, so no address that resolves today resolves differently now. + if (isImplicitOBPAccountScheme(scheme)) { + val implicitly = bankId match { + case Some(bankId) => + getBankAccountCommon(bankId, AccountId(address), callContext) + case None => + // No bank context — accept only when the account_id is globally unique. + MappedBankAccount.findAll(By(MappedBankAccount.theAccountId, address)) match { + case account :: Nil => Full((account, callContext)) + case Nil => Empty + case _ => + Failure(s"$AccountRoutingNotUnique (scheme: $scheme, address: $address)") + } + } + implicitly match { + // Nothing answers to the implicit reading, so try a registered routing. + case Empty => byRoutingTable + // A hit, or an ambiguity. `or` would have replaced the ambiguity with whatever the routing + // table said, which for an ambiguous address is nothing -- turning "this address matches + // several accounts" into a bare "not found". Keep what the implicit reading concluded. + case decided => decided + } + } else { + byRoutingTable + } } override def getBankAccountByRouting(bankId: Option[BankId], scheme: String, address: String, callContext: Option[CallContext]): OBPReturnType[Box[BankAccount]] = Future { @@ -1166,10 +1194,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { && (bankAccountRoutings.account.scheme.equalsIgnoreCase("OBP") || bankAccountRoutings.account.scheme.equalsIgnoreCase("OBP_ACCOUNT_ID"))){ for{ (_, callContext) <- NewStyle.function.getBank(BankId(bankAccountRoutings.bank.address), callContext) - (account, callContext) <- NewStyle.function.checkBankAccountExists( - BankId(bankAccountRoutings.bank.address), - AccountId(bankAccountRoutings.account.address), - callContext) + bankId = BankId(bankAccountRoutings.bank.address) + // The OBP scheme reads two ways -- the address is normally the account id, but a bank may + // also have registered an OBP routing whose address is something else. Ask the resolver + // that knows both rather than assuming the first, and fall back to checkBankAccountExists + // when neither answers, so a genuinely unknown account still reports itself the same way. + (account, callContext) <- getBankAccountByRoutingLegacy( + Some(bankId), bankAccountRoutings.account.scheme, bankAccountRoutings.account.address, callContext + ) match { + case Full((resolved, cc)) => Future.successful((resolved, cc)) + case _ => NewStyle.function.checkBankAccountExists( + bankId, AccountId(bankAccountRoutings.account.address), callContext) + } } yield { (account, callContext) } diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index 43512ad87d..c0b83269c0 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -170,6 +170,7 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with .mApiStandard(apiStandard.getOrElse(null)) .mUserId(callContext.flatMap(_.user.map(_.userId)).getOrElse(null)) .mOnBehalfOfUserId(callContext.flatMap(cc => cc.onBehalfOfUser.or(cc.consenter).map(_.userId)).getOrElse(null)) + .mConsumerId(callContext.flatMap(_.consumer.map(_.consumerId.get)).getOrElse(null)) // Explicit originator fields (FATF Rec 16, OPEN_CORRIDOR_PROMISE type only — null otherwise). .mOriginator_Name(explicitOriginator.map(_.name).getOrElse(null)) @@ -296,6 +297,7 @@ class MappedTransactionRequest extends LongKeyedMapper[MappedTransactionRequest] object mUserId extends MappedString(this, 100) object mOnBehalfOfUserId extends MappedString(this, 100) + object mConsumerId extends MappedString(this, 100) def updateStatus(newStatus: String) = { mStatus.set(newStatus) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala new file mode 100644 index 0000000000..7882dfafb3 --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala @@ -0,0 +1,144 @@ +package code.api.UKOpenBanking + +import code.api.util.OBPTransactionDirection +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} + +/** + * UK Open Banking splits a signed amount in two: `Amount` is unsigned (its pattern, + * `^\d{1,13}$|^\d{1,13}\.\d{1,5}$`, admits no sign) and the direction sits beside it in + * `CreditDebitIndicator`. OBP holds one signed BigDecimal, so every UK response has to split it. + * + * The factories used to hardcode `"Credit"` and pass the signed number straight through, which + * reported a debit of 25 as a credit of -25 — both halves wrong at once. These scenarios pin the + * split so neither half can drift back. + */ +class UKAmountsTest extends FeatureSpec with Matchers with GivenWhenThen { + + feature("UK Open Banking - splitting a signed amount into magnitude and direction") { + + scenario("a negative amount is a debit, reported as its magnitude") { + UKAmounts.creditDebitIndicator(BigDecimal("-25.00")) should be("Debit") + UKAmounts.unsignedAmount(BigDecimal("-25.00")) should be("25.00") + } + + scenario("a positive amount is a credit, unchanged") { + UKAmounts.creditDebitIndicator(BigDecimal("1209.06")) should be("Credit") + UKAmounts.unsignedAmount(BigDecimal("1209.06")) should be("1209.06") + } + + scenario("zero is a credit, as the standard states explicitly") { + UKAmounts.creditDebitIndicator(BigDecimal(0)) should be("Credit") + UKAmounts.unsignedAmount(BigDecimal(0)) should be("0") + } + + scenario("a missing amount is treated as zero, not as an error") { + UKAmounts.creditDebitIndicator(None: Option[BigDecimal]) should be("Credit") + UKAmounts.unsignedAmount(None: Option[BigDecimal]) should be("0") + UKAmounts.creditDebitIndicator(Some(BigDecimal("-1"))) should be("Debit") + UKAmounts.unsignedAmount(Some(BigDecimal("-1"))) should be("1") + } + + scenario("an amount OBP already holds as a string splits the same way") { + UKAmounts.creditDebitIndicatorOfString("-25.00") should be("Debit") + UKAmounts.unsignedAmountString("-25.00") should be("25.00") + UKAmounts.creditDebitIndicatorOfString("1209.06") should be("Credit") + UKAmounts.unsignedAmountString("1209.06") should be("1209.06") + } + + scenario("a value that is not a number is passed through rather than turned into a fabricated zero") { + UKAmounts.unsignedAmountString("") should be("") + UKAmounts.unsignedAmountString("not-a-number") should be("not-a-number") + UKAmounts.creditDebitIndicatorOfString("") should be("Credit") + } + + scenario("granting both directions, or neither, restricts nothing") { + // Neither is the plain ReadTransactionsBasic/Detail case; both is a TPP asking for everything. + for (amount <- List(BigDecimal("-25"), BigDecimal("25"), BigDecimal(0))) { + UKAmounts.admitsDirection(Some(amount), grantsCredits = false, grantsDebits = false) should be(true) + UKAmounts.admitsDirection(Some(amount), grantsCredits = true, grantsDebits = true) should be(true) + } + } + + scenario("granting only Credits admits credits and excludes debits") { + UKAmounts.admitsDirection(Some(BigDecimal("25")), grantsCredits = true, grantsDebits = false) should be(true) + UKAmounts.admitsDirection(Some(BigDecimal("-25")), grantsCredits = true, grantsDebits = false) should be(false) + // Zero is a credit, so a Credits-only consent sees it. + UKAmounts.admitsDirection(Some(BigDecimal(0)), grantsCredits = true, grantsDebits = false) should be(true) + } + + scenario("granting only Debits admits debits and excludes credits") { + UKAmounts.admitsDirection(Some(BigDecimal("-25")), grantsCredits = false, grantsDebits = true) should be(true) + UKAmounts.admitsDirection(Some(BigDecimal("25")), grantsCredits = false, grantsDebits = true) should be(false) + UKAmounts.admitsDirection(Some(BigDecimal(0)), grantsCredits = false, grantsDebits = true) should be(false) + } + + scenario("what a response labels Debit is what a Debits-only consent admits") { + // The two must agree, or a row could be labelled one direction and filtered as the other. + for (amount <- List(BigDecimal("-0.01"), BigDecimal("0"), BigDecimal("0.01"), BigDecimal("-1000"))) { + val labelledDebit = UKAmounts.creditDebitIndicator(amount) == "Debit" + UKAmounts.admitsDirection(Some(amount), grantsCredits = false, grantsDebits = true) should be(labelledDebit) + UKAmounts.admitsDirection(Some(amount), grantsCredits = true, grantsDebits = false) should be(!labelledDebit) + } + } + + scenario("a scale that would render in scientific notation still comes out plain") { + // BigDecimal("1E+3").toString is "1E+3", which the Amount pattern rejects. + UKAmounts.unsignedAmount(BigDecimal("1E+3")) should be("1000") + UKAmounts.unsignedAmount(BigDecimal("-1E+3")) should be("1000") + UKAmounts.unsignedAmountString("1E+3") should be("1000") + } + + scenario("the query restriction matches the directions granted") { + // Both or neither is no restriction, so no param is added at all. + UKAmounts.directionQueryParam(grantsCredits = true, grantsDebits = true) should be(Nil) + UKAmounts.directionQueryParam(grantsCredits = false, grantsDebits = false) should be(Nil) + UKAmounts.directionQueryParam(grantsCredits = true, grantsDebits = false) should + be(List(OBPTransactionDirection(credits = true))) + UKAmounts.directionQueryParam(grantsCredits = false, grantsDebits = true) should + be(List(OBPTransactionDirection(credits = false))) + } + + scenario("the query restriction and the post-filter agree on every amount") { + // They are two enforcements of one rule -- the database narrows, the filter is authoritative. + // If they disagreed, a row could be selected by one and dropped by the other. + // + // The boundary comes from OBPTransactionDirection -- the same value LocalMappedConnector + // builds its SQL predicate from -- rather than a copy written here. A local copy agrees with + // itself no matter what the connector does, so it could not detect the drift it exists to + // catch. + for (amount <- List(BigDecimal("-1000"), BigDecimal("-0.01"), BigDecimal(0), BigDecimal("0.01"))) { + for ((credits, debits) <- List((true, false), (false, true))) { + val smallestUnit = (amount * 100).toLongExact + val queryWouldKeep = UKAmounts.directionQueryParam(credits, debits) match { + case List(param) => OBPTransactionDirection.admits(param, smallestUnit) + case _ => true + } + withClue(s"amount $amount credits=$credits debits=$debits: ") { + UKAmounts.admitsDirection(Some(amount), credits, debits) should be(queryWouldKeep) + } + } + } + } + + scenario("an amount the view withheld is admitted by neither direction") { + // None here is "the moderating view did not grant CAN_SEE_TRANSACTION_AMOUNT", not "zero". + // creditDebitIndicator still labels it Credit for rendering, but as a permission test that + // would hand every debit to a Credits-only consent, so the restriction must refuse instead. + UKAmounts.admitsDirection(None, grantsCredits = true, grantsDebits = false) should be(false) + UKAmounts.admitsDirection(None, grantsCredits = false, grantsDebits = true) should be(false) + // With no restriction in force there is nothing to refuse. + UKAmounts.admitsDirection(None, grantsCredits = true, grantsDebits = true) should be(true) + UKAmounts.admitsDirection(None, grantsCredits = false, grantsDebits = false) should be(true) + } + + scenario("every produced Amount matches the standard's unsigned pattern") { + val pattern = "^\\d{1,13}$|^\\d{1,13}\\.\\d{1,5}$".r + List("-25.00", "25.00", "0", "-0.5", "1209.06", "-1234567890123").foreach { input => + val produced = UKAmounts.unsignedAmountString(input) + withClue(s"input $input produced $produced: ") { + pattern.findFirstIn(produced).isDefined should be(true) + } + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 04c50f6b0b..ef6d535c66 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -769,8 +769,16 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { val refused = makePostRequest(authoriseRequest(consentId) <@ (user2), s"""{"account_ids":["$acc"],"challenge_id":"$challengeId","answer":"123"}""") - refused.code should not equal 200 - refused.body.extract[ErrorMessage].message should include("OBP-35037") + // 403, the same answer the ConsentDoesNotMatchUser guard earlier in this endpoint gives: + // both are "you may not authorise this", not "your request was malformed". + refused.code should equal(403) + val message = refused.body.extract[ErrorMessage].message + message should include("OBP-35037") + // And it has to arrive as itself. Passing this Box through connectorEmptyResponse reported + // an authorisation decision as InvalidConnectorResponse, burying the reason behind a cause + // it has nothing to do with. + message should not include "OBP-50200" + message should not include "connectorEmptyResponse" // The regression: before the reorder both of these came back naming resourceUser2. boundPsuOf(consentId) should equal("") diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala index a76a9fbdd9..9e21e3dd4b 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala @@ -6,7 +6,7 @@ import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotM import code.model.UserX import code.model.dataAccess.ResourceUser import com.openbankproject.commons.util.ApiVersion -import net.liftweb.common.{Empty, Full} +import net.liftweb.common.{Empty, Failure, Full} import net.liftweb.util.Helpers.randomString import org.scalatest.Tag @@ -68,60 +68,82 @@ class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { feature("Consent.checkUKConsentAccess") { + // The ASPSP's own approval screen arrives under its own Consumer, never the TPP's, so the + // lodging-Consumer comparison refuses exactly the caller whose job is to show the PSU what they + // are being asked to grant -- and the screen renders with no permissions, status or expiry. + scenario("a declared SCA front end may read a consent nobody has claimed yet", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = true) should equal(None) + Consent.checkUKConsentAccess("", tpp, None, Some(otherTpp), callerIsScaFrontEnd = true) should equal(None) + } + + scenario("but not once a PSU has claimed it", UKOpenBankingV401ConsentAccess) { + // The window the approval screen exists for has closed; from here the PSU half governs, and a + // declared front end gets no further than anyone else would. + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(Some(ConsentDoesNotMatchUser)) + Consent.checkUKConsentAccess(psu, tpp, None, Some(otherTpp), callerIsScaFrontEnd = true) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("and an undeclared caller is still refused on an unclaimed consent", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + scenario("the PSU a consent is bound to may use it", UKOpenBankingV401ConsentAccess) { - Consent.checkUKConsentAccess(psu, tpp, Some(psu), Some(tpp)) should equal(None) + Consent.checkUKConsentAccess(psu, tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("a different PSU may not use a bound consent", UKOpenBankingV401ConsentAccess) { - Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp)) should + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchUser)) } scenario("the PSU check wins over the Consumer once a consent is bound", UKOpenBankingV401ConsentAccess) { // Even the Consumer that lodged it cannot act as another PSU. - Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp)) should + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchUser)) } scenario("an unbound consent may be used by the Consumer that lodged it", UKOpenBankingV401ConsentAccess) { - Consent.checkUKConsentAccess("", tpp, Some(psu), Some(tpp)) should equal(None) + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("an unbound consent may not be used by a second TPP", UKOpenBankingV401ConsentAccess) { - Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp)) should + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } // The client-credentials cases: no PSU in the session at all. scenario("a PSU-less call may use an unbound consent it lodged", UKOpenBankingV401ConsentAccess) { - Consent.checkUKConsentAccess("", tpp, None, Some(tpp)) should equal(None) + Consent.checkUKConsentAccess("", tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("a PSU-less call may use a bound consent it lodged", UKOpenBankingV401ConsentAccess) { // The one combination whose outcome changes, and the reason: this is how the standard has the // AISP poll and revoke its own consent after the PSU has authorised it. It used to be refused. - Consent.checkUKConsentAccess(psu, tpp, None, Some(tpp)) should equal(None) + Consent.checkUKConsentAccess(psu, tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("a PSU-less call from a second TPP is still refused", UKOpenBankingV401ConsentAccess) { // Dropping the user check does not open the consent to everyone: the Consumer still decides. - Consent.checkUKConsentAccess(psu, tpp, None, Some(otherTpp)) should + Consent.checkUKConsentAccess(psu, tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) - Consent.checkUKConsentAccess("", tpp, None, Some(otherTpp)) should + Consent.checkUKConsentAccess("", tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } scenario("a PSU-less call with no Consumer at all is refused", UKOpenBankingV401ConsentAccess) { - Consent.checkUKConsentAccess(psu, tpp, None, None) should equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkUKConsentAccess(psu, tpp, None, None, callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } scenario("blank ids count as absent, not as a value to match", UKOpenBankingV401ConsentAccess) { // A consent lodged before consumer binding existed stores no consumer id; nothing identifies a // wrong caller, so it cannot be refused on that basis. - Consent.checkUKConsentAccess("", "", None, Some(tpp)) should equal(None) - Consent.checkUKConsentAccess(null, null, None, None) should equal(None) + Consent.checkUKConsentAccess("", "", None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + Consent.checkUKConsentAccess(null, null, None, None, callerIsScaFrontEnd = false) should equal(None) // A blank caller user id is not a PSU either -- it must not accidentally match a blank binding. - Consent.checkUKConsentAccess(psu, tpp, Some(" "), Some(tpp)) should equal(None) + Consent.checkUKConsentAccess(psu, tpp, Some(" "), Some(tpp), callerIsScaFrontEnd = false) should equal(None) } } @@ -178,22 +200,45 @@ class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { val viaClientCredentials = CallContext(user = Full(pseudoUserOfConsumer), consumer = Full(testConsumer)) Consent.checkUKConsentAccess( - bound, lodger, Consent.actingPsu(viaClientCredentials).map(_.userId), Some(lodger)) should equal(None) + bound, lodger, Consent.actingPsu(viaClientCredentials).map(_.userId), Some(lodger), callerIsScaFrontEnd = false) should equal(None) val viaConsentHeader = CallContext( user = Full(shadowUserOfConsent), consenter = Full(resourceUser1), consumer = Full(testConsumer)) Consent.checkUKConsentAccess( - bound, lodger, Consent.actingPsu(viaConsentHeader).map(_.userId), Some(lodger)) should equal(None) + bound, lodger, Consent.actingPsu(viaConsentHeader).map(_.userId), Some(lodger), callerIsScaFrontEnd = false) should equal(None) // And it still narrows: a session acting as a different PSU cannot reach the consent, which is // the whole reason the user half is kept. val viaOtherPsu = CallContext(user = Full(resourceUser2), consumer = Full(testConsumer)) Consent.checkUKConsentAccess( - bound, lodger, Consent.actingPsu(viaOtherPsu).map(_.userId), Some(lodger)) should + bound, lodger, Consent.actingPsu(viaOtherPsu).map(_.userId), Some(lodger), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchUser)) } } + // A consent of another standard reaching a UK endpoint is a refusal, not a server fault. + // checkUKConsent used to throw when there was no Authorization header, which is exactly the + // shape such a request has: the dispatcher routes the consent into its own standard's branch, + // that branch authenticates the request, and ukConsentId is never set. The uncaught throw came + // back as OBP-50000 Unknown Error at 500. + feature("Consent.checkUKConsent refuses rather than throws when no UK consent is in play") { + + scenario("a request with neither a UK consent nor an Authorization header is refused", UKOpenBankingV401ConsentAccess) { + val result = Consent.checkUKConsent(resourceUser1, Some(CallContext())) + result match { + case Failure(msg, _, _) => msg should include("OBP-35036") + case other => fail(s"expected a Failure naming the standard mismatch, got $other") + } + } + + scenario("a request the consent header already settled is still waved through", UKOpenBankingV401ConsentAccess) { + // applyUKRules sets ukConsentId once it has run every gate, and this short-circuit is what + // keeps consent-header authentication working -- the refusal above must not reach it. + Consent.checkUKConsent(resourceUser1, Some(CallContext(ukConsentId = Some("any-consent-id")))) should + equal(Full(true)) + } + } + feature("consent-by-id ResourceDocs accept a client-credentials caller") { // Without this the docs default to UserOnly, which sends ResourceDocMiddleware down // anonymousAccess and 401s any request carrying no user -- so the rule above would never be diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala index e54e4c0f1f..43ffe95d84 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala @@ -48,49 +48,82 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { feature("Consent.checkBerlinGroupConsentAccess") { scenario("the TPP that lodged an unowned consent may authorise it", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(tpp)) should equal(None) + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("a second TPP may not authorise a consent it did not lodge", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp)) should + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } scenario("a PSU-less call may drive a consent its own Consumer lodged", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(tpp)) should equal(None) - Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(tpp)) should equal(None) + Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("a PSU-less call from a second TPP is still refused", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(otherTpp)) should + Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) - Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(otherTpp)) should + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } scenario("a PSU-less call with no Consumer at all is refused", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess(psu, tpp, None, None) should + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, None, callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } scenario("the PSU a consent is already bound to may re-authorise it", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(psu), Some(tpp)) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) } scenario("a different PSU may not re-bind a consent that is already owned", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(tpp)) should + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(tpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchUser)) } scenario("the PSU check wins over the Consumer once a consent is bound", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp)) should + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp), callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchUser)) } scenario("blank ids count as absent, not as a value to match", BerlinGroupV13ConsentAccess) { - Consent.checkBerlinGroupConsentAccess(null, null, None, None) should equal(None) - Consent.checkBerlinGroupConsentAccess(" ", tpp, Some(psu), Some(tpp)) should equal(None) - Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(" "), Some(tpp)) should equal(None) + Consent.checkBerlinGroupConsentAccess(null, null, None, None, callerIsScaFrontEnd = false) should equal(None) + Consent.checkBerlinGroupConsentAccess(" ", tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(" "), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + } + + // The Redirect approach: the PSU authenticates at the ASPSP, so these calls arrive from the + // ASPSP's own front end under its own Consumer -- never the one that lodged the consent. The + // same-TPP rule would refuse the only caller Redirect has, which is what blocked the scaRedirect + // ceremony outright. Nothing in the request separates that front end from a second TPP holding a + // PSU session, so it is declared rather than inferred; these pin that the declaration is the only + // thing that changes, and that it does not reach the PSU half. + feature("Consent.checkBerlinGroupConsentAccess and the ASPSP's declared SCA front end") { + + scenario("a declared front end may start an authorisation on a consent it did not lodge", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(None) + } + + scenario("a declared front end still cannot re-bind another PSU's consent", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("a declared front end acting for the consent's own PSU is fine", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(None) + } + + scenario("the declaration is by consumer id and nothing else", BerlinGroupV13ConsentAccess) { + // Empty config is the default, and it must leave the same-TPP rule applying to everyone. + Consent.isScaFrontEnd(Some(otherTpp)) should equal(false) + Consent.isScaFrontEnd(None) should equal(false) + Consent.isScaFrontEnd(Some(" ")) should equal(false) } } @@ -100,6 +133,22 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { // travelling in the body rather than in the session, so None is the normal answer for a // conforming call -- not a failure to defend against. checkBerlinGroupConsentAccess is written // for that: a caller with no PSU skips the PSU comparison and is judged on its Consumer alone. + // A refusal tells the TPP which view and which account it was refused, and that is all it + // should tell them. Under consent authentication the principal is the consent's own shadow + // user, so putting its id in the message hands the TPP an internal identifier it cannot act + // on and was never party to. + feature("BG v1.3 - a view refusal does not disclose the internal principal") { + scenario("the refusal names the view and the account, and no user id", BerlinGroupV13ConsentAccess) { + // user2 holds no Berlin Group view on testAccountId1, so this is a real refusal. + val response = makeGetRequest((V1_3_BG / "accounts" / testAccountId1.value / "balances").GET <@ (user2)) + response.code should equal(403) + val body = response.body.toString + body should include("OBP-20060") + body should not include "userId :" + body should not include resourceUser2.userId + } + } + feature("Consent.genuinePsu") { scenario("a session with no user at all has no PSU", BerlinGroupV13ConsentAccess) { @@ -123,9 +172,9 @@ class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { Consent.genuinePsu(CallContext(user = Full(pseudoUserOfTestConsumer), consumer = Empty)) .map(_.userId) should equal(Some(pseudoUserOfTestConsumer.userId)) - Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(pseudoUserOfTestConsumer.userId), None) should + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(pseudoUserOfTestConsumer.userId), None, callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchUser)) - Consent.checkBerlinGroupConsentAccess("", tpp, Some(pseudoUserOfTestConsumer.userId), None) should + Consent.checkBerlinGroupConsentAccess("", tpp, Some(pseudoUserOfTestConsumer.userId), None, callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) } } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala index fe162244ad..6b0ccce4c1 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala @@ -10,7 +10,11 @@ import code.api.util.APIUtil.OAuth._ import code.api.util.APIUtil.extractErrorMessageCode import code.api.util.ErrorMessages._ import code.model.dataAccess.{BankAccountRouting, MappedBankAccount} +import code.model.TokenType import code.setup.{APIResponse, DefaultUsers} +import code.token.Tokens +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.enums.{AccountRoutingScheme, PaymentServiceTypes, TransactionRequestTypes} @@ -758,11 +762,127 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with "NON_EXISTING_PAYMENT_ID" / "cancellation-authorisations").POST <@ (user1) val response: APIResponse = makeGetRequest(requestGet) - Then("We should get a 200 ") - response.code should equal(200) - val payment = response.body.extract[CancellationJsonV13] - payment.cancellationIds should be equals(0) + Then("We should get a 400 - the payment has to exist before its cancellation authorisations can be listed") + response.code should equal(400) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (InvalidTransactionRequestId) } } + // Berlin Group addresses a payment by its id alone. Nothing in the route ties the payment to the + // caller, so without an explicit check a paymentId is a bearer token: whoever holds it can read + // the payment, list and start authorisations on it, and cancel it. The payment records who lodged + // it; these scenarios hold every payment-scoped route to that record. + feature("test the BG v1.3 - a payment is only addressable by the party that initiated it") { + scenario("a second TPP can neither read, authorise, nor cancel a payment it did not initiate", BerlinGroupV1_3, PIS, initiatePayment) { + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + val ibanFrom = accountsRoutingIban.head + val ibanTo = accountsRoutingIban.last + + 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") + + grantAccountAccess(ibanFrom) + + // Over the challenge threshold, so the payment stays in RCVD awaiting SCA — the state in which + // a hijacked authorisation would actually move money. + val initiatePaymentJson = + s"""{ + | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "2001" }, + | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "creditorName": "70charname" + }""".stripMargin + + When("user1 initiates a payment") + val responseInitiate: APIResponse = makePostRequest( + (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1), + initiatePaymentJson) + responseInitiate.code should equal(201) + val paymentId = responseInitiate.body.extract[InitiatePaymentResponseJson].paymentId + + val fromBalanceBefore = balanceOf(ibanFrom) + val toBalanceBefore = balanceOf(ibanTo) + + val payment = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId + + Then("user2 is refused on every payment-scoped route") + val refusals = List( + "read the payment" -> makeGetRequest((payment).GET <@ (user2)), + "read its status" -> makeGetRequest((payment / "status").GET <@ (user2)), + "list its authorisations" -> makeGetRequest((payment / "authorisations").GET <@ (user2)), + "list its cancellation authorisations" -> makeGetRequest((payment / "cancellation-authorisations").GET <@ (user2)), + "start an authorisation on it" -> makePostRequest((payment / "authorisations").POST <@ (user2), """{"scaAuthenticationData":"123"}"""), + "cancel it" -> makeDeleteRequest((payment).DELETE <@ (user2)) + ) + refusals.foreach { case (what, response) => + withClue(s"user2 was allowed to $what: ") { + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (PaymentNotInitiatedByCaller) + } + } + + And("no money moved") + balanceOf(ibanFrom) should equal(fromBalanceBefore) + balanceOf(ibanTo) should equal(toBalanceBefore) + + And("user1, who initiated it, still can address it") + val ownerResponse = makeGetRequest((payment / "status").GET <@ (user1)) + ownerResponse.code should equal(200) + } + scenario("a second TPP acting for the same PSU is refused too", BerlinGroupV1_3, PIS, initiatePayment) { + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + val ibanFrom = accountsRoutingIban.head + val ibanTo = accountsRoutingIban.last + + grantAccountAccess(ibanFrom) + + val initiatePaymentJson = + s"""{ + | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "2001" }, + | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "creditorName": "70charname" + }""".stripMargin + + When("the PSU lodges a payment through one TPP") + val responseInitiate: APIResponse = makePostRequest( + (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1), + initiatePaymentJson) + responseInitiate.code should equal(201) + val paymentId = responseInitiate.body.extract[InitiatePaymentResponseJson].paymentId + val payment = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId + + Then("the same PSU acting through a second TPP still cannot address it") + // The person matches and only the TPP differs -- the case a person-only check waves through, + // and the reason the payment has to record which consumer lodged it. + val response = makeGetRequest((payment / "status").GET <@ (samePsuUnderSecondConsumer)) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (PaymentNotInitiatedByCaller) + + And("the TPP that lodged it still can") + makeGetRequest((payment / "status").GET <@ (user1)).code should equal(200) + } + } + // resourceUser1's own token, issued under a second consumer: same person, different TPP. + // DefaultUsers has no such pair -- user2 and user3 change the person as well as the consumer. + 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)) + } + + } \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala b/obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala new file mode 100644 index 0000000000..80535b185a --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala @@ -0,0 +1,54 @@ +package code.api.util + +import code.api.berlin.group.v1_3.BerlinGroupServerSetupV1_3 +import code.api.util.APIUtil.HTTPParam +import org.scalatest.Tag + +/** + * `frequencyPerDay` limits "an access without PSU involvement per day", so everything turns on how + * the ASPSP decides a PSU was not involved. NextGenPSD2 makes that a property of one header: + * `PSU-IP-Address` "shall be contained if and only if this request was actively initiated by the + * PSU". These scenarios pin that reading, in particular that a request carrying no PSU header at all + * counts against the limit — the case a TPP running unattended actually produces. + */ +class BerlinGroupPsuInvolvementTest extends BerlinGroupServerSetupV1_3 { + + object PsuInvolvement extends Tag("BerlinGroupPsuInvolvement") + + private def headers(pairs: (String, String)*): List[HTTPParam] = + pairs.map { case (name, value) => HTTPParam(name, List(value)) }.toList + + feature("Berlin Group - deciding whether the PSU was behind a request") { + + scenario("a request carrying no PSU-IP-Address is unattended", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("X-Request-ID" -> "5d8a7e2c-3c1f-4f7a-9a6e-1b0d2f3a4b5c")) should be(true) + } + + scenario("an empty or blank PSU-IP-Address is no address at all", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> "")) should be(true) + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> " ")) should be(true) + } + + scenario("a request carrying the PSU's address was initiated by the PSU", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> "192.168.8.78")) should be(false) + } + + scenario("the header name is matched case-insensitively, as HTTP requires", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("psu-ip-address" -> "192.168.8.78")) should be(false) + } + + scenario("the sentinel values still mark an unattended request", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> "0.0.0.0")) should be(true) + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("PSU-IP-Address" -> "192.168.8.78", "PSU-Device-ID" -> "no-psu-involved")) should be(true) + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("PSU-IP-Address" -> "192.168.8.78", "PSU-Device-Name" -> "no-psu-involved")) should be(true) + } + + scenario("a real device id alongside a PSU address does not make the request unattended", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("PSU-IP-Address" -> "192.168.8.78", "PSU-Device-ID" -> "99435c7e-ad88-49ec-a2ad-99ddcb1f7721")) should be(false) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala index b98fcf953d..cc57ae70ca 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala @@ -32,7 +32,15 @@ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.{accountRoutingJsonV121 import code.api.v5_0_0.ConsentJsonV500 import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ +import code.api.Constant +import com.openbankproject.commons.model.{AccountId, BankId, ViewId} import code.api.util.Consent +import code.consent.Consents +import code.counterpartylimit.CounterpartyLimitProvider +import code.metadata.counterparties.Counterparties +import code.views.system.{AccountAccess, ViewDefinition} +import scala.concurrent.Await +import scala.concurrent.duration._ import code.api.util.ErrorMessages._ import code.api.util.ExampleValue.counterpartyNameExample import code.api.v2_1_0.{CounterpartyIdJson, TransactionRequestBodyCounterpartyJSON} @@ -234,6 +242,51 @@ class VRPConsentRequestTest extends V510ServerSetup with PropsReset{ response.body.extract[TransactionRequestWithChargeJSON400].status shouldBe("COMPLETED") } + + scenario("Revoking a VRP consent releases the mandate it created", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { + When("the PSU creates a VRP consent request and converts it") + val createConsentResponse = makePostRequest(createVRPConsentRequestUrl, write(postVRPConsentRequestMonthlyGuardJson)) + createConsentResponse.code should equal(201) + val consentRequestId = createConsentResponse.body.extract[ConsentRequestResponseJson].consent_request_id + + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + val createConsentByRequestResponse = makePostRequest(createConsentByConsentRequestIdEmail(consentRequestId), write("")) + createConsentByRequestResponse.code should equal(201) + val consentJson = createConsentByRequestResponse.body.extract[ConsentJsonV500] + val consentId = consentJson.consent_id + val access = consentJson.account_access.getOrElse(fail("the converted consent named no account access")) + val bankId = BankId(access.bank_id) + val accountId = AccountId(access.account_id) + val viewId = ViewId(access.view_id) + viewId.value should startWith(Constant.VRP_VIEW_ID_PREFIX) + + Then("the mandate exists: the PSU holds the view, and its counterparty has a limit") + ViewDefinition.findCustomView(bankId.value, accountId.value, viewId.value).isDefined should be(true) + AccountAccess.findAllByBankIdAccountIdViewId(bankId, accountId, viewId).size should be > 0 + val counterparties = Counterparties.counterparties.vend.getCounterparties(bankId, accountId, viewId).getOrElse(Nil) + counterparties.size should be > 0 + counterparties.foreach { counterparty => + Await.result(CounterpartyLimitProvider.counterpartyLimit.vend.getCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterparty.counterpartyId), 10.seconds + ).isDefined should be(true) + } + + When("the consent is revoked") + Consents.consentProvider.vend.revoke(consentId).isDefined should be(true) + + Then("nothing of the mandate is left to pay with") + // The authority the PSU held, the amount it was good for, and the view that carried it. + AccountAccess.findAllByBankIdAccountIdViewId(bankId, accountId, viewId).size should be(0) + counterparties.foreach { counterparty => + Await.result(CounterpartyLimitProvider.counterpartyLimit.vend.getCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterparty.counterpartyId), 10.seconds + ).isDefined should be(false) + } + ViewDefinition.findCustomView(bankId.value, accountId.value, viewId.value).isDefined should be(false) + + And("the PSU's own access to the account is untouched") + AccountAccess.findAllByBankIdAccountIdViewId(bankId, accountId, ViewId(Constant.SYSTEM_OWNER_VIEW_ID)).size should be > 0 + } scenario("We will call the Create (IMPLICIT), Get and Delete endpoints with user credentials ", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, ApiEndpoint5, ApiEndpoint6, VersionOfApi) { When(s"We try $ApiEndpoint1 v5.1.0") val createConsentResponse = makePostRequest(createVRPConsentRequestUrl, write(postVRPConsentRequestMonthlyGuardJson)) diff --git a/obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala b/obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala new file mode 100644 index 0000000000..60ab856bd8 --- /dev/null +++ b/obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala @@ -0,0 +1,110 @@ +package code.bankconnectors + +import code.api.Constant +import code.model.dataAccess.BankAccountRouting +import code.setup.{DefaultUsers, ServerSetupWithTestData} +import com.openbankproject.commons.model.{AccountId, AccountRoutingJsonV121, BankAccountRoutings, BankId, BankRoutingJson, BranchRoutingJsonV141} +import net.liftweb.mapper.By +import scala.concurrent.Await +import scala.concurrent.duration._ +import org.scalatest.Tag + +/** + * The `OBP` account-routing scheme means two things at once, and resolution has to honour both. + * + * It is an implicit self-identifier: an address under it is normally the account id itself, with no + * row in bankaccountrouting. But a bank may also *register* an `OBP` routing whose address is + * something else entirely, and that row is stored like any other scheme's. Reading only the implicit + * meaning left those accounts unreachable through every endpoint that resolves by routing — the row + * was in the table, and the answer was "Bank Account not found", which is what blocked converting a + * consent-request that named an account that way. + */ +class ObpAccountRoutingResolutionTest extends ServerSetupWithTestData with DefaultUsers { + + object ObpRouting extends Tag("ObpAccountRoutingResolution") + + // "OBP" is the overloaded one, accepted in both bank- and account-routing contexts. + private val obpScheme = "OBP" + + feature("Resolving an account by an OBP-scheme routing") { + + scenario("an address that is the account id resolves, with and without a bank", ObpRouting) { + val account = createAccountRelevantResource(Some(resourceUser1), testBankId1, testAccountId1, "EUR") + + Connector.connector.vend.getBankAccountByRoutingLegacy( + Some(account.bankId), obpScheme, account.accountId.value, None + ).map(_._1.accountId) should equal(net.liftweb.common.Full(account.accountId)) + + } + + scenario("without a bank, an account id shared by several banks is reported as ambiguous", ObpRouting) { + // The fixture gives more than one bank an account called testAccount1, so with no bank context + // the address matches several accounts. That has to stay an ambiguity: falling through to the + // routing table would find nothing there and answer a bare "not found" instead. + createAccountRelevantResource(Some(resourceUser1), testBankId1, testAccountId1, "EUR") + createAccountRelevantResource(Some(resourceUser1), testBankId2, testAccountId1, "EUR") + + val result = Connector.connector.vend.getBankAccountByRoutingLegacy( + None, obpScheme, testAccountId1.value, None) + result.isDefined should equal(false) + result.toString should include("OBP-31075") + } + + scenario("a registered OBP routing whose address is not the account id resolves too", ObpRouting) { + val account = createAccountRelevantResource(Some(resourceUser1), testBankId2, AccountId("testAccountObpRouting"), "EUR") + val registeredAddress = "some-bank-chosen-obp-address" + + BankAccountRouting.create + .BankId(account.bankId.value) + .AccountId(account.accountId.value) + .AccountRoutingScheme(obpScheme) + .AccountRoutingAddress(registeredAddress) + .saveMe() + + Connector.connector.vend.getBankAccountByRoutingLegacy( + Some(account.bankId), obpScheme, registeredAddress, None + ).map(_._1.accountId) should equal(net.liftweb.common.Full(account.accountId)) + + Connector.connector.vend.getBankAccountByRoutingLegacy( + None, obpScheme, registeredAddress, None + ).map(_._1.accountId) should equal(net.liftweb.common.Full(account.accountId)) + } + + scenario("the plural-routings resolver honours a registered OBP routing too", ObpRouting) { + // getBankAccountByRoutings has its own copy of the implicit-OBP shortcut, and had the same + // blind spot. This is the path the VRP consent-request creation takes. + val account = createAccountRelevantResource(Some(resourceUser1), testBankId1, AccountId("testAccountPluralRouting"), "EUR") + val registeredAddress = "another-bank-chosen-obp-address" + + BankAccountRouting.create + .BankId(account.bankId.value) + .AccountId(account.accountId.value) + .AccountRoutingScheme(obpScheme) + .AccountRoutingAddress(registeredAddress) + .saveMe() + + val routings = BankAccountRoutings( + bank = BankRoutingJson(obpScheme, account.bankId.value), + account = BranchRoutingJsonV141(obpScheme, registeredAddress), + branch = AccountRoutingJsonV121("", "") + ) + val resolved = Await.result( + Connector.connector.vend.getBankAccountByRoutings(routings, None), 20.seconds)._1 + resolved.map(_.accountId) should equal(net.liftweb.common.Full(account.accountId)) + } + + scenario("an address that is neither still resolves to nothing", ObpRouting) { + Connector.connector.vend.getBankAccountByRoutingLegacy( + Some(BankId(testBankId1.value)), obpScheme, "no-such-address-anywhere", None + ).isDefined should equal(false) + } + } + + override def afterEach(): Unit = { + BankAccountRouting.findAll( + By(BankAccountRouting.AccountRoutingAddress, "some-bank-chosen-obp-address")).foreach(_.delete_!) + BankAccountRouting.findAll( + By(BankAccountRouting.AccountRoutingAddress, "another-bank-chosen-obp-address")).foreach(_.delete_!) + super.afterEach() + } +} From da41561f9af6e228f636c6ddea9a111a3d9d0751 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sun, 9 Aug 2026 11:28:54 +0200 Subject: [PATCH 58/63] fix: refuse a stranger TPP reading a consent's status or SCA status (#71) Four endpoints read a Berlin Group consent by id. Two compared the consent's lodging Consumer against the caller's; two did not, and answered any AISP that knew the consent id: GET /consents/{id}/status -> consentStatus GET /consents/{id}/authorisations/{id} -> scaStatus Both were already fetching the consent, to prove it exists, and then simply did not look at who was asking. So the id alone confirmed a consent existed and let its progress through authorisation be watched from outside. The PUT that answers an authorisation is guarded, so this disclosed state rather than granting access. Guarded the same way as the two that were already right. Verified with two TPPs against a running instance: the stranger now gets 403 on all four while the lodging TPP still gets 200 on all four. The probe covering this was the direct cause of the gap -- it asserted the one endpoint that had been noticed, so the other three went unexamined. It now walks the whole family, which is why the two that were open showed up at all. --- .../api/berlin/group/v1_3/Http4sBGv13AIS.scala | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 6fb92fb444..b7bc9acf82 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 @@ -362,9 +362,15 @@ object Http4sBGv13AIS extends MdcLoggable { val callContext = Some(cc) for { _ <- passesPsd2Aisp(callContext) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + // Same ownership test as the three sibling reads of this consent. The consent was already + // being fetched here purely to prove it exists, so the SCA status of anyone's consent was + // readable by any AISP that knew the id. + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)", 403) } + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) } yield { val challengeStatus = challenges.filter(_.challengeId == authorisationId) @@ -384,6 +390,12 @@ object Http4sBGv13AIS extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, ConsentNotFound, 403) } + // Same ownership test as the three sibling reads of this consent. Without it the status of + // any consent was readable by any AISP holding its id -- which is enough to confirm the + // consent exists and to watch it move through authorisation. + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } } yield { JSONFactory_BERLIN_GROUP_1_3.ConsentStatusJsonV13(consent.status) } From 3fdb59289dbde60551b947a27cdb1053c42d25ae Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sun, 9 Aug 2026 12:07:32 +0200 Subject: [PATCH 59/63] build: drop the two repositories on the retired OSSRH host (#72) oss.sonatype.org was retired and now answers 403 to everything, so the two repositories pointing at it could only ever fail. They did so intermittently rather than always, which is what made this hard to attribute: Maven consults a remote repository only when a POM is not already in the local cache, so a warm runner never touched them while a cold one failed the whole build before a single test ran. The same commit passed in one CI run and failed in the next, on a shard unrelated to anything that had changed. Nothing was ever resolved from them. Verified by resolving the full reactor, and then compiling it, against an empty local repository with both removed: BUILD SUCCESS, 2622 artifacts from Central and 12 from JitPack, zero requests to the dead host and zero unresolved artifacts or plugins. pluginRepositories contained only the dead entry, so the block goes with it; Maven consults Central for plugins by default, which the cold compile exercised. The two repositories that remain are load-bearing: git-OpenBankProject serves OBP's own published artifacts and jitpack.io serves the pinned lift-persistence build. --- pom.xml | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/pom.xml b/pom.xml index 90f956d09b..f9789a7c1f 100644 --- a/pom.xml +++ b/pom.xml @@ -43,12 +43,22 @@ obp-api + - - scala-tools.releases - Scala-Tools Dependencies Repository for Releases - https://oss.sonatype.org/content/repositories/releases/ - git-OpenBankProject OpenBankProject Git based repo @@ -60,14 +70,6 @@ - - - org.sonatype.oss.groups.public - Sonatype Public - https://oss.sonatype.org/content/groups/public - - -