diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 13a8bfa6c5..2d92353971 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -155,15 +155,24 @@ 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=$! + # 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 " Log: http4s-server.log (also $RUNTIME_LOG)" + echo " PORT: ${SERVER_PORT:-8080}" + 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. 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" 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 # ----------------------------------------------------------------------------- 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/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 03032bc919..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 @@ -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 @@ -47,24 +47,40 @@ 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] )) + // 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( - Some(u), + createdByUser, bankId = None, 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 +99,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 +212,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/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/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..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 @@ -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} @@ -94,24 +94,43 @@ 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] )) + // 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( - Some(u), + createdByUser, bankId = None, 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 +214,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" ) } @@ -2236,7 +2255,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(account.bankId, accountId.value, transactions, moderatedAttributes, view) } } resourceDocs += ResourceDoc( @@ -2305,6 +2324,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 +3467,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..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 @@ -132,15 +132,20 @@ 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, - TransactionFromDateTime: String, - TransactionToDateTime: String + ExpirationDateTime: Option[String], + TransactionFromDateTime: Option[String], + TransactionToDateTime: Option[String] ) case class ConsentResponseV401( Data: ConsentDataV401, @@ -294,11 +299,11 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { def createTransactionsJsonNew( bankId: BankId, + accountId: String, 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), @@ -319,22 +324,49 @@ 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, 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) 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/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/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index db2143d8d0..eddc6ecb04 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:: @@ -632,6 +634,97 @@ 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. + // 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 / 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, + 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/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 d0ba73f44c..819f4dc500 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. @@ -1040,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] @@ -1051,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) @@ -1107,6 +1120,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 @@ -1192,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/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 1f172a3e95..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 @@ -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 { @@ -104,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) { @@ -4281,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), @@ -4322,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) @@ -4337,24 +4360,42 @@ 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 { 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. 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 +4418,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 +4441,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, ConsentExpiredIssue, ConsentDoesNotMatchUser, InvalidJsonFormat, InvalidChallengeAnswer, $BankAccountNotFound, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsent) @@ -4578,7 +4627,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) } 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/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/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/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/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 93812381e3..06d32c8eb4 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,58 @@ 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_CREDITS_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION + ) + entity case _ => entity } 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..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 @@ -25,7 +25,9 @@ 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, + clientcertificate as client_certificate FROM consumer WHERE isactive = true -- Only expose active consumers to OIDC service ORDER BY client_name; 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 97d1ec205e..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 @@ -1,10 +1,20 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.Constant import code.api.util.APIUtil.DateWithDayFormat -import code.consent.Consents +import code.api.util.ErrorMessages.{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 net.liftweb.common.{Failure, Full} 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) @@ -12,13 +22,17 @@ 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 the Bearer access token -// to be a JWT carrying a consent_id claim bound to an AUTHORISED consent of the -// calling user+consumer (see code.api.util.ConsentUtil.checkUKConsent). The test -// framework's DirectLogin tokens carry no consent_id, 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). +// getBalances / getTransactions (the account-aggregate variants) share the same +// checkUKConsent guard but only assert "unauthenticated -> 401" / "authenticated -> +// not 401" (mirroring UKOpenBankingV310AisTests' precedent): they 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). @@ -46,9 +60,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") @@ -64,10 +78,63 @@ 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) } + 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) { @@ -76,6 +143,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) @@ -94,7 +163,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) @@ -103,11 +173,159 @@ 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) + } + } + + // 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") + } + } + } + + // 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). + } + + // 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 ──────────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (consent_id claim — 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) @@ -133,8 +351,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) @@ -237,10 +457,12 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } // ── TransactionsApi ──────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (consent_id claim — 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) @@ -248,23 +470,10 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } // ── BalancesApi ──────────────────────────────────────────────────── + // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (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) @@ -334,23 +543,10 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { getUnauthed("aisp", "statements").code should equal(401) } } + // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (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) 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 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)) + } }