From d9f28eef61a7f9db53698ef8ba7b36daf45a23f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 20 Dec 2024 11:57:38 +0100 Subject: [PATCH 1/2] feature/Add endpoint getConsents v5.1.0 --- .../scala/code/api/util/ConsentUtil.scala | 18 ++++++ .../scala/code/api/v5_1_0/APIMethods510.scala | 57 +++++++++++++++++++ .../scala/code/consent/MappedConsent.scala | 10 +++- .../scala/code/api/v5_1_0/ConsentsTest.scala | 30 ++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 35ee492bf9..ef3fe2709f 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -940,4 +940,22 @@ object Consent extends MdcLoggable { consentsOfBank } + def filterStrictlyByBank(consents: List[MappedConsent], bankId: String): List[MappedConsent] = { + implicit val formats = CustomJsonFormats.formats + val consentsOfBank = + consents.filter { consent => + val jsonWebTokenAsCaseClass: Box[ConsentJWT] = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) + .map(parse(_).extract[ConsentJWT]) + jsonWebTokenAsCaseClass match { + // There is a View related to Bank ID + case Full(consentJWT) if consentJWT.views.map(_.bank_id).contains(bankId) => true + // There is a Role related to Bank ID + case Full(consentJWT) if consentJWT.entitlements.map(_.bank_id).contains(bankId) => true + // Filter out Consent because there is no a View or a Role related to Bank ID + case _ => false + } + } + consentsOfBank + } + } diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index ace3752849..041ea2d8ff 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -1528,6 +1528,63 @@ trait APIMethods510 { } } + staticResourceDocs += ResourceDoc( + getConsents, + implementedInApiVersion, + nameOf(getConsents), + "GET", + "/management/consents", + "Get Consents", + s""" + | + |This endpoint gets the Consents. + | + |${authenticationRequiredMessage(true)} + | + |1 limit (for pagination: defaults to 50) eg:limit=200 + | + |2 offset (for pagination: zero index, defaults to 0) eg: offset=10 + | + |3 consumer_id (ignore if omitted) + | + |4 consent_id (ignore if omitted) + | + |5 user_id (ignore if omitted) + | + |6 status (ignore if omitted) + | + |7 bank_id (ignore if omitted) + | + |eg:/management/consents?consumer_id=78&limit=10&offset=10 + | + """.stripMargin, + EmptyBody, + consentsJsonV510, + List( + $UserNotLoggedIn, + $BankNotFound, + UnknownError + ), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), + Some(List(canGetConsentsAtAnyBank)), + ) + + lazy val getConsents: OBPEndpoint = { + case "management" :: "consents" :: Nil JsonGet _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) + consents <- Future { + Consents.consentProvider.vend.getConsents(obpQueryParams) + } + } yield { + (createConsentsJsonV510(consents), HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( getConsentByConsentId, diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 4f74436c70..d21ad614f5 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -1,7 +1,7 @@ package code.consent import java.util.Date -import code.api.util.{APIUtil, Consent, ErrorMessages, OBPConsentId, OBPConsumerId, OBPLimit, OBPOffset, OBPQueryParam, OBPStatus, OBPUserId, SecureRandomUtil} +import code.api.util.{APIUtil, Consent, ErrorMessages, OBPBankId, OBPConsentId, OBPConsumerId, OBPLimit, OBPOffset, OBPQueryParam, OBPStatus, OBPUserId, SecureRandomUtil} import code.consent.ConsentStatus.ConsentStatus import code.model.Consumer import code.util.MappedUUID @@ -85,7 +85,13 @@ object MappedConsentProvider extends ConsentProvider { override def getConsents(queryParams: List[OBPQueryParam]): List[MappedConsent] = { val optionalParams = getQueryParams(queryParams) - MappedConsent.findAll(optionalParams: _*) + val consents = MappedConsent.findAll(optionalParams: _*) + val bankId: Option[String] = queryParams.collectFirst { case OBPBankId(value) => value } + if(bankId.isDefined) { + Consent.filterStrictlyByBank(consents, bankId.get) + } else { + consents + } } override def createObpConsent(user: User, challengeAnswer: String, consentRequestId:Option[String], consumer: Option[Consumer]): Box[MappedConsent] = { tryo { diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentsTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentsTest.scala index 95d7a53997..c8f7750258 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsentsTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentsTest.scala @@ -67,6 +67,7 @@ class ConsentsTest extends V510ServerSetup with PropsReset{ object ApiEndpoint7 extends Tag(nameOf(Implementations5_1_0.getConsentByConsentId)) object ApiEndpoint8 extends Tag(nameOf(Implementations5_1_0.getMyConsents)) object ApiEndpoint9 extends Tag(nameOf(Implementations5_1_0.getConsentsAtBank)) + object GetConsents extends Tag(nameOf(Implementations5_1_0.getConsents)) object UpdateConsentStatusByConsent extends Tag(nameOf(Implementations5_1_0.updateConsentStatusByConsent)) object UpdateConsentAccountAccessByConsentId extends Tag(nameOf(Implementations5_1_0.updateConsentAccountAccessByConsentId)) @@ -93,6 +94,7 @@ class ConsentsTest extends V510ServerSetup with PropsReset{ def revokeConsentUrl(consentId: String) = (v5_1_0_Request / "banks" / bankId / "consents" / consentId).DELETE def getMyConsents(consentId: String) = (v5_1_0_Request / "banks" / bankId / "my" / "consents").GET def getConsentsAtBAnk(consentId: String) = (v5_1_0_Request / "management"/ "consents" / "banks" / bankId).GET + def getConsents(consentId: String) = (v5_1_0_Request / "management"/ "consents").GET def updateConsentStatusByConsent(consentId: String) = (v5_1_0_Request / "management" / "banks" / bankId / "consents" / consentId).PUT def updateConsentPayloadByConsent(consentId: String) = (v5_1_0_Request / "management" / "banks" / bankId / "consents" / consentId / "account-access").PUT @@ -153,6 +155,34 @@ class ConsentsTest extends V510ServerSetup with PropsReset{ } } + feature(s"test $GetConsents version $VersionOfApi - Unauthenticated access") { + scenario("We will call the endpoint without user credentials", GetConsents, VersionOfApi) { + When(s"We make a request $GetConsents") + val response510 = makeGetRequest(getConsents("whatever")) + Then("We should get a 401") + response510.code should equal(401) + response510.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + } + feature(s"test $GetConsents version $VersionOfApi - Authenticated access") { + scenario("We will call the endpoint with user credentials", GetConsents, VersionOfApi) { + When(s"We make a request $ApiEndpoint1") + val response510 = makeGetRequest(getConsents("whatever") <@ (user1)) + Then("We should get a 403") + response510.code should equal(403) + response510.body.extract[ErrorMessage].message contains (UserHasMissingRoles + s"$CanGetConsentsAtAnyBank") should be(true) + } + } + feature(s"test $GetConsents version $VersionOfApi - Authenticated access with proper entitlement") { + scenario("We will call the endpoint with user credentials", GetConsents, VersionOfApi) { + When(s"We make a request $ApiEndpoint1") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetConsentsAtAnyBank.toString) + val response510 = makeGetRequest(getConsents("whatever") <@ (user1)) + Then("We should get a 200") + response510.code should equal(200) + } + } + feature(s"test $UpdateConsentStatusByConsent version $VersionOfApi - Unauthenticated access") { scenario("We will call the endpoint without user credentials", UpdateConsentStatusByConsent, VersionOfApi) { From 1dc4ee2eb301ced04a02428a8bbf22265b7895df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 20 Dec 2024 12:37:37 +0100 Subject: [PATCH 2/2] feature/Add props oauth2.keycloak.client_ids --- .../main/resources/props/sample.props.template | 2 ++ obp-api/src/main/scala/code/api/OAuth2.scala | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 85d4b3c11b..7f226bfc8b 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -769,6 +769,8 @@ display_internal_errors=false # oauth2.keycloak.well_known=http://localhost:7070/realms/master/.well-known/openid-configuration # Used to sync IAM of OBP-API and IAM of Keycloak # oauth2.keycloak.source_of_truth = false +# LIst of clients allowed to sync IAM of OBP-API and IAM of Keycloak +# oauth2.keycloak.client_ids = SOME_CLIENT_ID_1, SOME_CLIENT_ID_2 # ------------------------------------------------------------------------------ OAuth 2 ------ # -- PSU Authentication methods -------------------------------------------------------------- diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 99fa9990f8..6901690c62 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -517,16 +517,25 @@ object OAuth2Login extends RestHelper with MdcLoggable { private def addScopesToConsumer(token: String, consumerPrimaryKey: Long): Unit = { val sourceOfTruth = APIUtil.getPropsAsBoolValue(nameOfProperty = "oauth2.keycloak.source_of_truth", defaultValue = false) + // Consumers allowed to use the source of truth feature + val consumerIds: List[String] = + APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.client_ids").toList + .flatMap(_.split(",").toList) val consumerId = getClaim(name = "azp", idToken = token).getOrElse("") if(sourceOfTruth) { logger.debug("Extracting roles from Access Token") import net.liftweb.json._ val jsonString = JwtUtil.getSignedPayloadAsJson(token) val json = parse(jsonString.getOrElse("")) - val openBankRoles: List[String] = { - (json \ "resource_access" \ consumerId \ "roles").extract[List[String]] - .filter(role => tryo(ApiRole.valueOf(role)).isDefined) // Keep only the roles OBP-API can recognise - } + val openBankRoles: List[String] = + if(consumerIds.contains(consumerId)) { + // Sync Keycloak's roles + (json \ "resource_access" \ consumerId \ "roles").extract[List[String]] + .filter(role => tryo(ApiRole.valueOf(role)).isDefined) // Keep only the roles OBP-API can recognise + } else { + // Clean up roles assigned to "consumerId" + List() + } val scopes = Scope.scope.vend.getScopesByConsumerId(consumerPrimaryKey.toString).getOrElse(Nil) val databaseState = scopes.map(_.roleName) // Already exist at DB