From 9e63372e404610688d5403a0c751842b2435328a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 26 Sep 2025 09:23:41 +0200 Subject: [PATCH 1/4] feature/Call limits endpoints --- .../SwaggerDefinitionsJSON.scala | 42 ++++ .../main/scala/code/api/util/ApiRole.scala | 6 +- .../main/scala/code/api/util/NewStyle.scala | 6 + .../scala/code/api/v6_0_0/APIMethods600.scala | 174 ++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 76 +++++++ .../ratelimiting/MappedRateLimiting.scala | 20 ++ .../code/ratelimiting/RateLimiting.scala | 3 + .../code/api/v6_0_0/CallLimitsTest.scala | 200 ++++++++++++++++++ 8 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/CallLimitsTest.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index f0b2b05d73..e28953da16 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -3965,6 +3965,48 @@ object SwaggerDefinitionsJSON { ) ) + lazy val callLimitPostJsonV600 = CallLimitPostJsonV600( + from_date = DateWithDayExampleObject, + to_date = DateWithDayExampleObject, + api_version = Some("v6.0.0"), + api_name = Some("getConsumerCallLimits"), + bank_id = None, + per_second_call_limit = "100", + per_minute_call_limit = "1000", + per_hour_call_limit = "-1", + per_day_call_limit = "-1", + per_week_call_limit = "-1", + per_month_call_limit = "-1" + ) + + lazy val callLimitJsonV600 = CallLimitJsonV600( + rate_limiting_id = "80e1e0b2-d8bf-4f85-a579-e69ef36e3305", + from_date = DateWithDayExampleObject, + to_date = DateWithDayExampleObject, + api_version = Some("v6.0.0"), + api_name = Some("getConsumerCallLimits"), + bank_id = None, + per_second_call_limit = "100", + per_minute_call_limit = "1000", + per_hour_call_limit = "-1", + per_day_call_limit = "-1", + per_week_call_limit = "-1", + per_month_call_limit = "-1", + created_at = DateWithDayExampleObject, + updated_at = DateWithDayExampleObject + ) + + lazy val activeCallLimitsJsonV600 = ActiveCallLimitsJsonV600( + call_limits = List(callLimitJsonV600), + active_at_date = DateWithDayExampleObject, + total_per_second_call_limit = 100, + total_per_minute_call_limit = 1000, + total_per_hour_call_limit = -1, + total_per_day_call_limit = -1, + total_per_week_call_limit = -1, + total_per_month_call_limit = -1 + ) + lazy val accountWebhookPostJson = AccountWebhookPostJson( account_id =accountIdExample.value, trigger_name = ApiTrigger.onBalanceChange.toString(), diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 6d72decf89..d07208a039 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -483,6 +483,9 @@ object ApiRole extends MdcLoggable{ case class CanSetCallLimits(requiresBankId: Boolean = false) extends ApiRole lazy val canSetCallLimits = CanSetCallLimits() + case class CanDeleteRateLimiting(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteRateLimiting = CanDeleteRateLimiting() + case class CanCreateCustomerMessage(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateCustomerMessage = CanCreateCustomerMessage() @@ -1168,7 +1171,8 @@ object Util { "CanCheckFundsAvailable", "CanRefreshUser", "CanReadFx", - "CanSetCallLimits" + "CanSetCallLimits", + "CanDeleteRateLimiting" ) val allowed = allowedPrefixes ::: allowedExistingNames diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 8a297199c2..4fee5772c3 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -90,6 +90,12 @@ object NewStyle extends MdcLoggable{ def `204`(callContext: CallContext): Option[CallContext] = { Some(callContext.copy(httpCode = Some(204))) } + def `400`(callContext: CallContext): Option[CallContext] = { + Some(callContext.copy(httpCode = Some(400))) + } + def `400`(callContext: Option[CallContext]): Option[CallContext] = { + callContext.map(_.copy(httpCode = Some(400))) + } def `404`(callContext: CallContext): Option[CallContext] = { Some(callContext.copy(httpCode = Some(404))) } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 4c35facab9..03d55c3544 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1,25 +1,29 @@ package code.api.v6_0_0 +import code.api.{APIFailureNewStyle, ObpApiFailure} import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil._ -import code.api.util.ApiRole.canReadCallLimits +import code.api.util.ApiRole.{canDeleteRateLimiting, canReadCallLimits, canSetCallLimits} import code.api.util.ApiTag._ -import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidJsonFormat, UnknownError, _} +import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, InvalidJsonFormat, UnknownError, _} import code.api.util.FutureUtil.EndpointContext import code.api.util.{NewStyle, RateLimitingUtil} import code.api.util.NewStyle.HttpCode -import code.api.v6_0_0.JSONFactory600.createCurrentUsageJson +import code.api.v6_0_0.JSONFactory600.{createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.bankconnectors.LocalMappedConnectorInternal import code.bankconnectors.LocalMappedConnectorInternal._ import code.entitlement.Entitlement +import code.ratelimiting.RateLimitingDI import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model._ import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.Full +import net.liftweb.common.{Box, Empty, Full} import net.liftweb.http.rest.RestHelper import com.openbankproject.commons.ExecutionContext.Implicits.global +import java.text.SimpleDateFormat +import java.util.Date import scala.collection.immutable.{List, Nil} import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future @@ -81,6 +85,168 @@ trait APIMethods600 { } + staticResourceDocs += ResourceDoc( + createCallLimits, + implementedInApiVersion, + nameOf(createCallLimits), + "POST", + "/management/consumers/CONSUMER_ID/consumer/call-limits", + "Create Call Limits for a Consumer", + s""" + |Create Call Limits for a Consumer + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + callLimitPostJsonV600, + callLimitJsonV600, + List( + $UserNotLoggedIn, + InvalidJsonFormat, + InvalidConsumerId, + ConsumerNotFoundByConsumerId, + UserHasMissingRoles, + UnknownError + ), + List(apiTagConsumer), + Some(List(canSetCallLimits))) + + + lazy val createCallLimits: OBPEndpoint = { + case "management" :: "consumers" :: consumerId :: "consumer" :: "call-limits" :: Nil JsonPost json -> _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canSetCallLimits, callContext) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CallLimitPostJsonV600 ", 400, callContext) { + json.extract[CallLimitPostJsonV600] + } + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, callContext) + rateLimiting <- RateLimitingDI.rateLimiting.vend.createOrUpdateConsumerCallLimits( + consumerId, + postJson.from_date, + postJson.to_date, + postJson.api_version, + postJson.api_name, + postJson.bank_id, + Some(postJson.per_second_call_limit), + Some(postJson.per_minute_call_limit), + Some(postJson.per_hour_call_limit), + Some(postJson.per_day_call_limit), + Some(postJson.per_week_call_limit), + Some(postJson.per_month_call_limit) + ) + } yield { + rateLimiting match { + case Full(rateLimitingObj) => (createCallLimitJsonV600(rateLimitingObj), HttpCode.`201`(callContext)) + case _ => (UnknownError, HttpCode.`400`(callContext)) + } + } + } + + + staticResourceDocs += ResourceDoc( + deleteCallLimits, + implementedInApiVersion, + nameOf(deleteCallLimits), + "DELETE", + "/management/consumers/CONSUMER_ID/consumer/call-limits/RATE_LIMITING_ID", + "Delete Call Limit by Rate Limiting ID", + s""" + |Delete a specific Call Limit by Rate Limiting ID + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $UserNotLoggedIn, + InvalidConsumerId, + ConsumerNotFoundByConsumerId, + UserHasMissingRoles, + UnknownError + ), + List(apiTagConsumer), + Some(List(canDeleteRateLimiting))) + + + lazy val deleteCallLimits: OBPEndpoint = { + case "management" :: "consumers" :: consumerId :: "consumer" :: "call-limits" :: rateLimitingId :: Nil JsonDelete _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canDeleteRateLimiting, callContext) + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, callContext) + rateLimiting <- RateLimitingDI.rateLimiting.vend.getByRateLimitingId(rateLimitingId) + _ <- rateLimiting match { + case Full(rl) if rl.consumerId == consumerId => + Future.successful(Full(rl)) + case Full(_) => + Future.successful(ObpApiFailure(s"Rate limiting ID $rateLimitingId does not belong to consumer $consumerId", 400, callContext)) + case _ => + Future.successful(ObpApiFailure(s"Rate limiting ID $rateLimitingId not found", 404, callContext)) + } + deleteResult <- RateLimitingDI.rateLimiting.vend.deleteByRateLimitingId(rateLimitingId) + } yield { + deleteResult match { + case Full(true) => (EmptyBody, HttpCode.`204`(callContext)) + case _ => (UnknownError, HttpCode.`400`(callContext)) + } + } + } + + + staticResourceDocs += ResourceDoc( + getActiveCallLimitsAtDate, + implementedInApiVersion, + nameOf(getActiveCallLimitsAtDate), + "GET", + "/management/consumers/CONSUMER_ID/consumer/call-limits/active-at-date/DATE", + "Get Active Call Limits at Date", + s""" + |Get the sum of call limits at a certain date time. This returns a SUM of all the records that span that time. + | + |Date format: YYYY-MM-DDTHH:MM:SSZ (e.g. 1099-12-31T23:00:00Z) + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + activeCallLimitsJsonV600, + List( + $UserNotLoggedIn, + InvalidConsumerId, + ConsumerNotFoundByConsumerId, + UserHasMissingRoles, + InvalidDateFormat, + UnknownError + ), + List(apiTagConsumer), + Some(List(canReadCallLimits))) + + + lazy val getActiveCallLimitsAtDate: OBPEndpoint = { + case "management" :: "consumers" :: consumerId :: "consumer" :: "call-limits" :: "active-at-date" :: dateString :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canReadCallLimits, callContext) + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, callContext) + date <- NewStyle.function.tryons(s"$InvalidDateFormat Current date format is: $dateString. Please use this format: YYYY-MM-DDTHH:MM:SSZ (e.g. 1099-12-31T23:00:00Z)", 400, callContext) { + val format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + format.parse(dateString) + } + activeCallLimits <- RateLimitingDI.rateLimiting.vend.getActiveCallLimitsByConsumerIdAtDate(consumerId, date) + } yield { + (createActiveCallLimitsJsonV600(activeCallLimits, date), HttpCode.`200`(callContext)) + } + } + + staticResourceDocs += ResourceDoc( getCurrentUser, implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 4c339ac468..3b91bb4004 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -35,6 +35,7 @@ import code.api.v3_1_0.{RateLimit, RedisCallLimitJson} import code.entitlement.Entitlement import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ +import java.util.Date case class CardanoPaymentJsonV600( address: String, @@ -57,6 +58,48 @@ case class CardanoMetadataStringJsonV600( string: String ) +case class CallLimitPostJsonV600( + from_date: java.util.Date, + to_date: java.util.Date, + api_version: Option[String] = None, + api_name: Option[String] = None, + bank_id: Option[String] = None, + per_second_call_limit: String, + per_minute_call_limit: String, + per_hour_call_limit: String, + per_day_call_limit: String, + per_week_call_limit: String, + per_month_call_limit: String +) + +case class CallLimitJsonV600( + rate_limiting_id: String, + from_date: java.util.Date, + to_date: java.util.Date, + api_version: Option[String], + api_name: Option[String], + bank_id: Option[String], + per_second_call_limit: String, + per_minute_call_limit: String, + per_hour_call_limit: String, + per_day_call_limit: String, + per_week_call_limit: String, + per_month_call_limit: String, + created_at: java.util.Date, + updated_at: java.util.Date +) + +case class ActiveCallLimitsJsonV600( + call_limits: List[CallLimitJsonV600], + active_at_date: java.util.Date, + total_per_second_call_limit: Long, + total_per_minute_call_limit: Long, + total_per_hour_call_limit: Long, + total_per_day_call_limit: Long, + total_per_week_call_limit: Long, + total_per_month_call_limit: Long +) + case class TransactionRequestBodyCardanoJsonV600( to: CardanoPaymentJsonV600, value: AmountOfMoneyJsonV121, @@ -129,4 +172,37 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable{ } ) } + + def createCallLimitJsonV600(rateLimiting: code.ratelimiting.RateLimiting): CallLimitJsonV600 = { + CallLimitJsonV600( + rate_limiting_id = rateLimiting.rateLimitingId, + from_date = rateLimiting.fromDate, + to_date = rateLimiting.toDate, + api_version = rateLimiting.apiVersion, + api_name = rateLimiting.apiName, + bank_id = rateLimiting.bankId, + per_second_call_limit = rateLimiting.perSecondCallLimit.toString, + per_minute_call_limit = rateLimiting.perMinuteCallLimit.toString, + per_hour_call_limit = rateLimiting.perHourCallLimit.toString, + per_day_call_limit = rateLimiting.perDayCallLimit.toString, + per_week_call_limit = rateLimiting.perWeekCallLimit.toString, + per_month_call_limit = rateLimiting.perMonthCallLimit.toString, + created_at = rateLimiting.createdAt.get, + updated_at = rateLimiting.updatedAt.get + ) + } + + def createActiveCallLimitsJsonV600(rateLimitings: List[code.ratelimiting.RateLimiting], activeDate: java.util.Date): ActiveCallLimitsJsonV600 = { + val callLimits = rateLimitings.map(createCallLimitJsonV600) + ActiveCallLimitsJsonV600( + call_limits = callLimits, + active_at_date = activeDate, + total_per_second_call_limit = rateLimitings.map(_.perSecondCallLimit).sum, + total_per_minute_call_limit = rateLimitings.map(_.perMinuteCallLimit).sum, + total_per_hour_call_limit = rateLimitings.map(_.perHourCallLimit).sum, + total_per_day_call_limit = rateLimitings.map(_.perDayCallLimit).sum, + total_per_week_call_limit = rateLimitings.map(_.perWeekCallLimit).sum, + total_per_month_call_limit = rateLimitings.map(_.perMonthCallLimit).sum + ) + } } \ No newline at end of file diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index bf01e1eaaa..03f46f6182 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -168,6 +168,26 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait { result } + def deleteByRateLimitingId(rateLimitingId: String): Future[Box[Boolean]] = Future { + tryo { + RateLimiting.find(By(RateLimiting.RateLimitingId, rateLimitingId)) match { + case Full(rateLimiting) => rateLimiting.delete_! + case _ => false + } + } + } + + def getByRateLimitingId(rateLimitingId: String): Future[Box[RateLimiting]] = Future { + RateLimiting.find(By(RateLimiting.RateLimitingId, rateLimitingId)) + } + + def getActiveCallLimitsByConsumerIdAtDate(consumerId: String, date: Date): Future[List[RateLimiting]] = Future { + RateLimiting.findAll( + By(RateLimiting.ConsumerId, consumerId), + By_<=(RateLimiting.FromDate, date), + By_>=(RateLimiting.ToDate, date) + ) + } } diff --git a/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala index 27fc319c54..5dfbf0ba27 100644 --- a/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala @@ -30,6 +30,9 @@ trait RateLimitingProviderTrait { perDay: Option[String], perWeek: Option[String], perMonth: Option[String]): Future[Box[RateLimiting]] + def deleteByRateLimitingId(rateLimitingId: String): Future[Box[Boolean]] + def getByRateLimitingId(rateLimitingId: String): Future[Box[RateLimiting]] + def getActiveCallLimitsByConsumerIdAtDate(consumerId: String, date: Date): Future[List[RateLimiting]] } trait RateLimitingTrait { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CallLimitsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CallLimitsTest.scala new file mode 100644 index 0000000000..915d4b2b9d --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/CallLimitsTest.scala @@ -0,0 +1,200 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2019, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.{CanDeleteRateLimiting, CanReadCallLimits, CanSetCallLimits} +import code.api.util.ErrorMessages.{UserHasMissingRoles, UserNotLoggedIn} +import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import code.consumer.Consumers +import code.entitlement.Entitlement +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + +import java.time.format.DateTimeFormatter +import java.time.{ZoneOffset, ZonedDateTime} +import java.util.Date + +class CallLimitsTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.createCallLimits)) + object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.deleteCallLimits)) + object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.getActiveCallLimitsAtDate)) + + lazy val postCallLimitJsonV600 = CallLimitPostJsonV600( + from_date = new Date(), + to_date = new Date(System.currentTimeMillis() + 86400000L), // +1 day + api_version = Some("v6.0.0"), + api_name = Some("testEndpoint"), + bank_id = None, + per_second_call_limit = "10", + per_minute_call_limit = "100", + per_hour_call_limit = "1000", + per_day_call_limit = "-1", + per_week_call_limit = "-1", + per_month_call_limit = "-1" + ) + + override def beforeAll() = { + super.beforeAll() + } + + override def beforeEach() = { + super.beforeEach() + } + + feature("POST Create Call Limits v6.0.0 - Unauthorized access") { + scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0 without user credentials") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + val request600 = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").POST + val response600 = makePostRequest(request600, write(postCallLimitJsonV600)) + Then("We should get a 401") + response600.code should equal(401) + And("error should be " + UserNotLoggedIn) + response600.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + } + + feature("POST Create Call Limits v6.0.0 - Authorized access") { + scenario("We will call the endpoint without proper Role", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0 without a proper role") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + val request600 = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").POST <@ (user1) + val response600 = makePostRequest(request600, write(postCallLimitJsonV600)) + Then("We should get a 403") + response600.code should equal(403) + And("error should be " + UserHasMissingRoles + CanSetCallLimits) + response600.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanSetCallLimits) + } + + scenario("We will call the endpoint with proper Role", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0 with a proper role") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanSetCallLimits.toString) + val request600 = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").POST <@ (user1) + val response600 = makePostRequest(request600, write(postCallLimitJsonV600)) + Then("We should get a 201") + response600.code should equal(201) + And("we should get the correct response format") + val callLimitResponse = response600.body.extract[CallLimitJsonV600] + callLimitResponse.per_second_call_limit should equal("10") + callLimitResponse.per_minute_call_limit should equal("100") + callLimitResponse.per_hour_call_limit should equal("1000") + } + } + + feature("DELETE Call Limits v6.0.0") { + scenario("We will delete a call limit by rate limiting ID", ApiEndpoint2, VersionOfApi) { + Given("We create a call limit first") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanSetCallLimits.toString) + val request600 = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").POST <@ (user1) + val createResponse = makePostRequest(request600, write(postCallLimitJsonV600)) + createResponse.code should equal(201) + val createdCallLimit = createResponse.body.extract[CallLimitJsonV600] + + When("We delete the call limit") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteRateLimiting.toString) + val deleteRequest = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits" / createdCallLimit.rate_limiting_id).DELETE <@ (user1) + val deleteResponse = makeDeleteRequest(deleteRequest) + + Then("We should get a 204") + deleteResponse.code should equal(204) + } + + scenario("We will try to delete without proper role", ApiEndpoint2, VersionOfApi) { + Given("We create a call limit first") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanSetCallLimits.toString) + val request600 = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").POST <@ (user1) + val createResponse = makePostRequest(request600, write(postCallLimitJsonV600)) + createResponse.code should equal(201) + val createdCallLimit = createResponse.body.extract[CallLimitJsonV600] + + When("We try to delete without proper role") + val deleteRequest = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits" / createdCallLimit.rate_limiting_id).DELETE <@ (user1) + val deleteResponse = makeDeleteRequest(deleteRequest) + + Then("We should get a 403") + deleteResponse.code should equal(403) + And("error should be " + UserHasMissingRoles + CanDeleteRateLimiting) + deleteResponse.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanDeleteRateLimiting) + } + } + + feature("GET Active Call Limits at Date v6.0.0") { + scenario("We will get active call limits at a specific date", ApiEndpoint3, VersionOfApi) { + Given("We create a call limit first") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanSetCallLimits.toString) + val request600 = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").POST <@ (user1) + val createResponse = makePostRequest(request600, write(postCallLimitJsonV600)) + createResponse.code should equal(201) + + When("We get active call limits at current date") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanReadCallLimits.toString) + val currentDateString = ZonedDateTime + .now(ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")) + val getRequest = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits" / "active-at-date" / currentDateString).GET <@ (user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + And("we should get the active call limits response") + val activeCallLimits = getResponse.body.extract[ActiveCallLimitsJsonV600] + activeCallLimits.call_limits should not be empty + activeCallLimits.total_per_second_call_limit should be > 0L + } + + scenario("We will try to get active call limits without proper role", ApiEndpoint3, VersionOfApi) { + When("We try to get active call limits without proper role") + val Some((c, _)) = user1 + val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") + val currentDateString = ZonedDateTime + .now(ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")) + val getRequest = (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits" / "active-at-date" / currentDateString).GET <@ (user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 403") + getResponse.code should equal(403) + And("error should be " + UserHasMissingRoles + CanReadCallLimits) + getResponse.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanReadCallLimits) + } + } +} \ No newline at end of file From f4115db7ed1dfe8c34ad55584f7c8d1e8bd6700a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 29 Sep 2025 10:02:20 +0200 Subject: [PATCH 2/4] feature/Add secure logging --- .../resources/props/sample.props.template | 46 +++ obp-api/src/main/scala/code/util/Helper.scala | 45 +-- .../main/scala/code/util/SecureLogging.scala | 204 ++++++++++++++ .../scala/code/util/SecureLoggingDemo.scala | 261 ++++++++++++++++++ 4 files changed, 538 insertions(+), 18 deletions(-) create mode 100644 obp-api/src/main/scala/code/util/SecureLogging.scala create mode 100644 obp-api/src/main/scala/code/util/SecureLoggingDemo.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 19f59f0f51..48bbac6f87 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1566,3 +1566,49 @@ redis_logging_circuit_breaker_reset_ms = 60000 # Experimental Developer Use only experimental_become_user_that_created_consent=false + + +### ============================================================ +### Secure Logging Masking Configuration +### Default: true (masking ON) +### Set to false to disable masking for a given pattern +### ============================================================ + +# OAuth2 / API secrets +securelogging_mask_secret=true +securelogging_mask_client_secret=true +securelogging_mask_client_id=true + +# Authorization / Tokens +securelogging_mask_authorization=true +securelogging_mask_access_token=true +securelogging_mask_refresh_token=true +securelogging_mask_id_token=true +securelogging_mask_token=true + +# Passwords +securelogging_mask_password=true +securelogging_mask_passwd=true +securelogging_mask_pass=true + +# API keys +securelogging_mask_api_key=true +securelogging_mask_apikey=true +securelogging_mask_key=true +securelogging_mask_private_key=true + +# Session / CSRF +securelogging_mask_session=true +securelogging_mask_csrf=true +securelogging_mask_xsrf=true + +# Database +securelogging_mask_jdbc=true +securelogging_mask_mongodb=true + +# Credit card +securelogging_mask_credit_card=true + +# Email addresses +securelogging_mask_email=true + diff --git a/obp-api/src/main/scala/code/util/Helper.scala b/obp-api/src/main/scala/code/util/Helper.scala index c155d62c5c..6ffaaf168c 100644 --- a/obp-api/src/main/scala/code/util/Helper.scala +++ b/obp-api/src/main/scala/code/util/Helper.scala @@ -343,52 +343,61 @@ object Helper extends Loggable { // INFO override def info(msg: => AnyRef): Unit = { - underlyingLogger.info(msg) - RedisLogger.logAsync(RedisLogger.LogLevel.INFO, toRedisFormat(msg)) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.info(maskedMsg) + RedisLogger.logAsync(RedisLogger.LogLevel.INFO, toRedisFormat(maskedMsg)) } override def info(msg: => AnyRef, t: => Throwable): Unit = { - underlyingLogger.info(msg, t) - RedisLogger.logAsync(RedisLogger.LogLevel.INFO, toRedisFormat(msg) + "\n" + t.toString) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.info(maskedMsg, t) + RedisLogger.logAsync(RedisLogger.LogLevel.INFO, toRedisFormat(maskedMsg) + "\n" + t.toString) } // WARN override def warn(msg: => AnyRef): Unit = { - underlyingLogger.warn(msg) - RedisLogger.logAsync(RedisLogger.LogLevel.WARNING, toRedisFormat(msg)) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.warn(maskedMsg) + RedisLogger.logAsync(RedisLogger.LogLevel.WARNING, toRedisFormat(maskedMsg)) } override def warn(msg: => AnyRef, t: Throwable): Unit = { - underlyingLogger.warn(msg, t) - RedisLogger.logAsync(RedisLogger.LogLevel.WARNING, toRedisFormat(msg) + "\n" + t.toString) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.warn(maskedMsg, t) + RedisLogger.logAsync(RedisLogger.LogLevel.WARNING, toRedisFormat(maskedMsg) + "\n" + t.toString) } // ERROR override def error(msg: => AnyRef): Unit = { - underlyingLogger.error(msg) - RedisLogger.logAsync(RedisLogger.LogLevel.ERROR, toRedisFormat(msg)) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.error(maskedMsg) + RedisLogger.logAsync(RedisLogger.LogLevel.ERROR, toRedisFormat(maskedMsg)) } override def error(msg: => AnyRef, t: Throwable): Unit = { - underlyingLogger.error(msg, t) - RedisLogger.logAsync(RedisLogger.LogLevel.ERROR, toRedisFormat(msg) + "\n" + t.toString) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.error(maskedMsg, t) + RedisLogger.logAsync(RedisLogger.LogLevel.ERROR, toRedisFormat(maskedMsg) + "\n" + t.toString) } // DEBUG override def debug(msg: => AnyRef): Unit = { - underlyingLogger.debug(msg) - RedisLogger.logAsync(RedisLogger.LogLevel.DEBUG, toRedisFormat(msg)) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.debug(maskedMsg) + RedisLogger.logAsync(RedisLogger.LogLevel.DEBUG, toRedisFormat(maskedMsg)) } override def debug(msg: => AnyRef, t: Throwable): Unit = { - underlyingLogger.debug(msg, t) - RedisLogger.logAsync(RedisLogger.LogLevel.DEBUG, toRedisFormat(msg) + "\n" + t.toString) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.debug(maskedMsg, t) + RedisLogger.logAsync(RedisLogger.LogLevel.DEBUG, toRedisFormat(maskedMsg) + "\n" + t.toString) } // TRACE override def trace(msg: => AnyRef): Unit = { - underlyingLogger.trace(msg) - RedisLogger.logAsync(RedisLogger.LogLevel.TRACE, toRedisFormat(msg)) + val maskedMsg = SecureLogging.maskSensitive(msg) + underlyingLogger.trace(maskedMsg) + RedisLogger.logAsync(RedisLogger.LogLevel.TRACE, toRedisFormat(maskedMsg)) } // Delegate enabled checks diff --git a/obp-api/src/main/scala/code/util/SecureLogging.scala b/obp-api/src/main/scala/code/util/SecureLogging.scala new file mode 100644 index 0000000000..5456c67d9a --- /dev/null +++ b/obp-api/src/main/scala/code/util/SecureLogging.scala @@ -0,0 +1,204 @@ +package code.util + +import code.api.util.APIUtil + +import java.util.regex.Pattern +import scala.collection.mutable + +/** + * SecureLogging utility for masking sensitive data in logs. + * + * Each pattern can be toggled via props: + * securelogging_mask_client_secret=true|false + * + * Default: all patterns enabled (true) + */ +object SecureLogging { + + /** + * ✅ Conditional inclusion helper using APIUtil.getPropsAsBoolValue + */ + private def conditionalPattern( + prop: String, + defaultValue: Boolean = true + )(pattern: => (Pattern, String)): Option[(Pattern, String)] = { + if (APIUtil.getPropsAsBoolValue(prop, defaultValue)) Some(pattern) else None + } + + /** + * ✅ Toggleable sensitive patterns + */ + private lazy val sensitivePatterns: List[(Pattern, String)] = { + val patterns = Seq( + // OAuth2 / API secrets + conditionalPattern("securelogging_mask_secret") { + (Pattern.compile("(?i)(secret=)([^,\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_client_secret") { + (Pattern.compile("(?i)(client_secret[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_client_id") { + (Pattern.compile("(?i)(client_id[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + + // Authorization / Tokens + conditionalPattern("securelogging_mask_authorization") { + (Pattern.compile("(?i)(Authorization:\\s*Bearer\\s+)([^\\s,&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_access_token") { + (Pattern.compile("(?i)(access_token[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_refresh_token") { + (Pattern.compile("(?i)(refresh_token[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_id_token") { + (Pattern.compile("(?i)(id_token[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_token") { + (Pattern.compile("(?i)(token[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + + // Passwords + conditionalPattern("securelogging_mask_password") { + (Pattern.compile("(?i)(password[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_passwd") { + (Pattern.compile("(?i)(passwd[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_pass") { + (Pattern.compile("(?i)(pass[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + + // API keys + conditionalPattern("securelogging_mask_api_key") { + (Pattern.compile("(?i)(api_key[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_apikey") { + (Pattern.compile("(?i)(apikey[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_key") { + (Pattern.compile("(?i)(key[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_private_key") { + (Pattern.compile("(?i)(private_key[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + + // Session / CSRF + conditionalPattern("securelogging_mask_session") { + (Pattern.compile("(?i)(session[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_csrf") { + (Pattern.compile("(?i)(csrf[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + conditionalPattern("securelogging_mask_xsrf") { + (Pattern.compile("(?i)(xsrf[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") + }, + + // Database connections + conditionalPattern("securelogging_mask_jdbc") { + (Pattern.compile("(?i)(jdbc:[^\\s]+://[^:]+:)([^@\\s]+)(@)"), "$1***$3") + }, + conditionalPattern("securelogging_mask_mongodb") { + (Pattern.compile("(?i)(mongodb://[^:]+:)([^@\\s]+)(@)"), "$1***$3") + }, + + // Credit cards + conditionalPattern("securelogging_mask_credit_card") { + (Pattern.compile("\\b([0-9]{4})[\\s-]?([0-9]{4})[\\s-]?([0-9]{4})[\\s-]?([0-9]{3,7})\\b"), "$1-****-****-$4") + }, + + // Emails + conditionalPattern("securelogging_mask_email") { + (Pattern.compile("(?i)(email[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+@[^\"',\\s&]+)"), "$1***@***.***") + } + ) + + patterns.flatten.toList + } + + // ===== Pattern cache for custom usage ===== + private val customPatternCache: mutable.Map[String, Pattern] = mutable.Map.empty + private def getOrCompileCustomPattern(regex: String): Pattern = + customPatternCache.getOrElseUpdate(regex, Pattern.compile(regex, Pattern.CASE_INSENSITIVE)) + + // ===== Masking Logic ===== + def maskSensitive(msg: AnyRef): String = { + val msgString = Option(msg).map(_.toString).getOrElse("") + if (msgString.isEmpty) return msgString + + sensitivePatterns.foldLeft(msgString) { case (acc, (pattern, replacement)) => + pattern.matcher(acc).replaceAll(replacement) + } + } + + def maskSensitive(msg: String): String = maskSensitive(msg.asInstanceOf[AnyRef]) + + // ===== Safe Logging ===== + def safeInfo(logger: net.liftweb.common.Logger, msg: => AnyRef): Unit = + logger.info(maskSensitive(msg)) + + def safeInfo(logger: net.liftweb.common.Logger, msg: => AnyRef, t: => Throwable): Unit = + logger.info(maskSensitive(msg), t) + + def safeWarn(logger: net.liftweb.common.Logger, msg: => AnyRef): Unit = + logger.warn(maskSensitive(msg)) + + def safeWarn(logger: net.liftweb.common.Logger, msg: => AnyRef, t: Throwable): Unit = + logger.warn(maskSensitive(msg), t) + + def safeError(logger: net.liftweb.common.Logger, msg: => AnyRef): Unit = + logger.error(maskSensitive(msg)) + + def safeError(logger: net.liftweb.common.Logger, msg: => AnyRef, t: Throwable): Unit = + logger.error(maskSensitive(msg), t) + + def safeDebug(logger: net.liftweb.common.Logger, msg: => AnyRef): Unit = + logger.debug(maskSensitive(msg)) + + def safeDebug(logger: net.liftweb.common.Logger, msg: => AnyRef, t: Throwable): Unit = + logger.debug(maskSensitive(msg), t) + + def safeTrace(logger: net.liftweb.common.Logger, msg: => AnyRef): Unit = + logger.trace(maskSensitive(msg)) + + def safeTrace(logger: net.liftweb.common.Logger, msg: => AnyRef, t: Throwable): Unit = + logger.trace(maskSensitive(msg).asInstanceOf[AnyRef], t) + + + // ===== Custom Masking ===== + def maskWithCustomPattern(pattern: String, replacement: String, msg: String): String = { + val compiledPattern = getOrCompileCustomPattern(pattern) + val masked = maskSensitive(msg) + compiledPattern.matcher(masked).replaceAll(replacement) + } + + /** + * ✅ Test method to demonstrate the masking functionality. + */ + def testMasking(): List[(String, String)] = { + val testMessages = List( + "OBP-50014: Can not refresh User. secret=V6knYTLivzqHeTjBKf0X1DTCa8q4rzyJOq3AiLHsCDM", + """{"client_secret": "mySecretKey123", "access_token": "tokenABC"}""", + "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + "password=supersecret123&username=testuser", + "api_key=sk_test_1234567890abcdef", + "Error connecting to jdbc:mysql://localhost:3306/obp?user=admin:secretpassword@dbhost", + "Credit card: 4532-1234-5678-9012 was processed", + "User email: sensitive@example.com in auth context" + ) + testMessages.map(msg => (msg, maskSensitive(msg))) + } + + /** + * ✅ Print test results to console for manual verification. + */ + def printTestResults(): Unit = { + println("\n=== SecureLogging Test Results ===") + testMasking().foreach { case (original, masked) => + println(s"Original: $original") + println(s"Masked: $masked") + println("---") + } + println("=== End Test Results ===\n") + } +} diff --git a/obp-api/src/main/scala/code/util/SecureLoggingDemo.scala b/obp-api/src/main/scala/code/util/SecureLoggingDemo.scala new file mode 100644 index 0000000000..24148e3ea3 --- /dev/null +++ b/obp-api/src/main/scala/code/util/SecureLoggingDemo.scala @@ -0,0 +1,261 @@ +package code.util + +import code.util.Helper.MdcLoggable +import net.liftweb.common.Loggable + +/** + * SecureLoggingDemo - Demonstration of secure logging functionality in OBP API + * + * This file demonstrates how to use the secure logging features to automatically + * mask sensitive data like secrets, tokens, passwords, and API keys from log output. + * + * The secure logging functionality is implemented in two ways: + * 1. Automatically in MdcLoggable trait - all logging is automatically masked + * 2. Manually using SecureLogging utility methods for fine-grained control + */ +object SecureLoggingDemo extends Loggable { + + /** + * Example class that extends MdcLoggable to get automatic secure logging + */ + class OAuthService extends MdcLoggable { + + def authenticateUser(clientSecret: String, accessToken: String): Unit = { + // This will automatically mask sensitive data before logging + logger.info(s"Authenticating user with client_secret=$clientSecret and access_token=$accessToken") + + // Simulate an OAuth error that might contain sensitive data + val errorMsg = s"OBP-50014: OAuth authentication failed. client_secret=$clientSecret, token=$accessToken" + logger.error(errorMsg) + + // Even complex JSON-like strings are masked + val jsonResponse = s"""{"client_secret": "$clientSecret", "access_token": "$accessToken", "status": "failed"}""" + logger.warn(s"OAuth response: $jsonResponse") + } + + def processPayment(cardNumber: String, apiKey: String): Unit = { + // Credit cards and API keys are also masked + logger.info(s"Processing payment for card $cardNumber with api_key=$apiKey") + + // Database connection strings with passwords are masked too + val dbUrl = "jdbc:mysql://localhost:3306/obp?user=admin:supersecret123@dbhost" + logger.debug(s"Connecting to database: $dbUrl") + } + } + + /** + * Example of using SecureLogging utility methods directly + */ + class PaymentService extends Loggable { + + def processTransaction(authHeader: String, clientId: String): Unit = { + // Using SecureLogging utility methods for manual control + SecureLogging.safeInfo(logger, s"Processing transaction with $authHeader and client_id=$clientId") + + // You can also mask messages before using them elsewhere + val sensitiveMessage = s"Transaction failed: Authorization: Bearer abc123xyz, client_secret=mySecret" + val maskedMessage = SecureLogging.maskSensitive(sensitiveMessage) + + // Now you can safely use maskedMessage anywhere + println(s"Safe message: $maskedMessage") + + // Or log it normally since it's already masked + logger.error(maskedMessage) + } + } + + /** + * Demonstration of various sensitive data patterns that are automatically masked + */ + def demonstratePatterns(): Unit = { + println("\n=== Secure Logging Pattern Demonstration ===\n") + + val testCases = List( + // OAuth2 and API secrets + ("OAuth Secret", "client_secret=abc123def456"), + ("API Key", "api_key=sk_live_1234567890abcdef"), + ("Generic Secret", "secret=V6knYTLivzqHeTjBKf0X1DTCa8q4rzyJOq3AiLHsCDM"), + + // Tokens and authorization + ("Bearer Token", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + ("Access Token", "access_token=ya29.a0ARrdaM9example"), + ("Refresh Token", "refresh_token=1//04example"), + + // JSON format + ("JSON Secrets", """{"client_secret": "mySecret", "access_token": "token123"}"""), + + // Passwords + ("Password", "password=supersecret123&username=john"), + ("Database URL", "jdbc:mysql://user:password123@localhost:3306/db"), + + // Credit cards + ("Credit Card", "Payment with card 4532-1234-5678-9012 was processed"), + + // Email in sensitive context + ("Email", "email=admin@example.com in auth context") + ) + + testCases.foreach { case (label, original) => + val masked = SecureLogging.maskSensitive(original) + println(s"$label:") + println(s" Original: $original") + println(s" Masked: $masked") + println() + } + } + + /** + * Example of how to create custom masking patterns for specific use cases + */ + def demonstrateCustomMasking(): Unit = { + println("=== Custom Masking Patterns ===\n") + + // Example: Mask custom internal identifiers + val message = "Processing user internal_id=USER_12345_SECRET and session_key=SESS_ABCD_XYZ" + + // Create custom pattern for internal IDs + val customPattern = "(?i)(internal_id=)([^\\s,&]+)" + val customReplacement = "$1***" + + val maskedWithCustom = SecureLogging.maskWithCustomPattern(customPattern, customReplacement, message) + + println(s"Original: $message") + println(s"Custom Masked: $maskedWithCustom") + println() + } + + /** + * Performance demonstration - showing that masking has minimal overhead + */ + def demonstratePerformance(): Unit = { + println("=== Performance Test ===\n") + + val testMessage = "client_secret=abc123 password=secret token=xyz789" + val iterations = 100000 + + // Test without masking + val startTime1 = System.nanoTime() + for (_ <- 1 to iterations) { + val _ = testMessage.toString // Simulate normal logging + } + val time1 = System.nanoTime() - startTime1 + + // Test with masking + val startTime2 = System.nanoTime() + for (_ <- 1 to iterations) { + val _ = SecureLogging.maskSensitive(testMessage) + } + val time2 = System.nanoTime() - startTime2 + + println(s"Normal logging (${iterations} iterations): ${time1/1000000} ms") + println(s"Secure logging (${iterations} iterations): ${time2/1000000} ms") + println(s"Overhead: ${((time2.toDouble/time1.toDouble - 1) * 100).round}%") + println() + } + + /** + * Main demonstration method + */ + def main(args: Array[String]): Unit = { + println("OBP API Secure Logging Demonstration") + println("====================================\n") + + // 1. Demonstrate automatic masking with MdcLoggable + println("1. Automatic Masking with MdcLoggable:") + val oauthService = new OAuthService() + oauthService.authenticateUser("myClientSecret123", "accessToken456") + oauthService.processPayment("4532-1234-5678-9012", "sk_test_abcd1234") + println() + + // 2. Demonstrate manual masking with SecureLogging utility + println("2. Manual Masking with SecureLogging Utility:") + val paymentService = new PaymentService() + paymentService.processTransaction("Authorization: Bearer xyz789", "client_12345") + println() + + // 3. Show all supported patterns + demonstratePatterns() + + // 4. Custom masking patterns + demonstrateCustomMasking() + + // 5. Performance test + demonstratePerformance() + + // 6. Run built-in tests + println("=== Built-in Test Results ===") + SecureLogging.printTestResults() + + println("Demonstration complete!") + } + + /** + * Integration example for existing OBP API code + */ + object IntegrationExamples { + + /** + * Example: How to retrofit existing logging in API endpoints + */ + class AccountsEndpoint extends MdcLoggable { + + def getAccount(authHeader: String, accountId: String): Unit = { + // Old way (potentially unsafe): + // logger.info(s"Getting account $accountId with auth $authHeader") + + // New way (automatically safe with MdcLoggable): + logger.info(s"Getting account $accountId with auth $authHeader") + + // For existing loggers that don't use MdcLoggable: + // SecureLogging.safeInfo(someExistingLogger, s"Message with $authHeader") + } + + // Make this method accessible for demonstration + def demonstrateLogging(authHeader: String, accountId: String): Unit = { + getAccount(authHeader, accountId) + } + } + + /** + * Example: How to handle exceptions that might contain sensitive data + */ + def handleOAuthException(exception: Exception): Unit = { + // Exception messages might contain sensitive data + val errorMessage = s"OAuth error: ${exception.getMessage}" + + // Safe way to log exceptions using SecureLogging utility + val logger = net.liftweb.common.Logger("OAuthHandler") + SecureLogging.safeError(logger, errorMessage, exception) + + // Or manually mask if needed + val maskedError = SecureLogging.maskSensitive(errorMessage) + println(s"Safe error message: $maskedError") + } + + /** + * Demonstrate the AccountsEndpoint functionality + */ + def demonstrateAccountEndpoint(): Unit = { + val endpoint = new AccountsEndpoint() + endpoint.demonstrateLogging("Authorization: Bearer secret123", "account456") + } + + /** + * Example: Configuration and connection string logging + */ + def logConfiguration(): Unit = { + val configs = Map( + "database.url" -> "jdbc:postgresql://user:password123@localhost:5432/obp", + "redis.url" -> "redis://admin:secret456@redis-server:6379", + "oauth.client_secret" -> "oauth_secret_xyz789", + "api.key" -> "api_key_abc123" + ) + + val logger = net.liftweb.common.Logger("ConfigLogger") + configs.foreach { case (key, value) => + // This will automatically mask sensitive values using SecureLogging + SecureLogging.safeDebug(logger, s"Config $key = $value") + } + } + } +} \ No newline at end of file From 8f8d3ea45eb343d071507e44a1981cd97f74df6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 29 Sep 2025 13:36:22 +0200 Subject: [PATCH 3/4] feature/Add secure logging 2 --- .../resources/props/sample.props.template | 13 +------- .../main/scala/code/util/SecureLogging.scala | 32 ++----------------- 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 48bbac6f87..9d95ac8605 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1577,7 +1577,6 @@ experimental_become_user_that_created_consent=false # OAuth2 / API secrets securelogging_mask_secret=true securelogging_mask_client_secret=true -securelogging_mask_client_id=true # Authorization / Tokens securelogging_mask_authorization=true @@ -1588,27 +1587,17 @@ securelogging_mask_token=true # Passwords securelogging_mask_password=true -securelogging_mask_passwd=true -securelogging_mask_pass=true # API keys securelogging_mask_api_key=true -securelogging_mask_apikey=true securelogging_mask_key=true securelogging_mask_private_key=true -# Session / CSRF -securelogging_mask_session=true -securelogging_mask_csrf=true -securelogging_mask_xsrf=true - # Database securelogging_mask_jdbc=true -securelogging_mask_mongodb=true # Credit card securelogging_mask_credit_card=true # Email addresses -securelogging_mask_email=true - +securelogging_mask_email=true \ No newline at end of file diff --git a/obp-api/src/main/scala/code/util/SecureLogging.scala b/obp-api/src/main/scala/code/util/SecureLogging.scala index 5456c67d9a..98ec9bfe0f 100644 --- a/obp-api/src/main/scala/code/util/SecureLogging.scala +++ b/obp-api/src/main/scala/code/util/SecureLogging.scala @@ -37,9 +37,6 @@ object SecureLogging { conditionalPattern("securelogging_mask_client_secret") { (Pattern.compile("(?i)(client_secret[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") }, - conditionalPattern("securelogging_mask_client_id") { - (Pattern.compile("(?i)(client_id[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, // Authorization / Tokens conditionalPattern("securelogging_mask_authorization") { @@ -62,20 +59,11 @@ object SecureLogging { conditionalPattern("securelogging_mask_password") { (Pattern.compile("(?i)(password[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") }, - conditionalPattern("securelogging_mask_passwd") { - (Pattern.compile("(?i)(passwd[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, - conditionalPattern("securelogging_mask_pass") { - (Pattern.compile("(?i)(pass[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, // API keys conditionalPattern("securelogging_mask_api_key") { (Pattern.compile("(?i)(api_key[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") }, - conditionalPattern("securelogging_mask_apikey") { - (Pattern.compile("(?i)(apikey[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, conditionalPattern("securelogging_mask_key") { (Pattern.compile("(?i)(key[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") }, @@ -83,31 +71,17 @@ object SecureLogging { (Pattern.compile("(?i)(private_key[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") }, - // Session / CSRF - conditionalPattern("securelogging_mask_session") { - (Pattern.compile("(?i)(session[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, - conditionalPattern("securelogging_mask_csrf") { - (Pattern.compile("(?i)(csrf[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, - conditionalPattern("securelogging_mask_xsrf") { - (Pattern.compile("(?i)(xsrf[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+)"), "$1***") - }, - - // Database connections + // Database conditionalPattern("securelogging_mask_jdbc") { (Pattern.compile("(?i)(jdbc:[^\\s]+://[^:]+:)([^@\\s]+)(@)"), "$1***$3") }, - conditionalPattern("securelogging_mask_mongodb") { - (Pattern.compile("(?i)(mongodb://[^:]+:)([^@\\s]+)(@)"), "$1***$3") - }, - // Credit cards + // Credit card conditionalPattern("securelogging_mask_credit_card") { (Pattern.compile("\\b([0-9]{4})[\\s-]?([0-9]{4})[\\s-]?([0-9]{4})[\\s-]?([0-9]{3,7})\\b"), "$1-****-****-$4") }, - // Emails + // Email addresses conditionalPattern("securelogging_mask_email") { (Pattern.compile("(?i)(email[\"']?\\s*[:=]\\s*[\"']?)([^\"',\\s&]+@[^\"',\\s&]+)"), "$1***@***.***") } From 008ceb5510dd165f061df2b239871af6a4b921fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 1 Oct 2025 12:30:30 +0200 Subject: [PATCH 4/4] feature/Tweak endpoint well-known v5.1.0 --- .../resources/props/sample.props.template | 22 ++++++- .../scala/code/api/v5_1_0/APIMethods510.scala | 65 ++++++++++++++----- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 9d95ac8605..7294085dfe 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -845,8 +845,26 @@ display_internal_errors=false # -- OBP OIDC OAuth 2 / OIDC --------------------------------------------------- # To run OBP OIDC (for developer testing) see: https://github.com/OpenBankProject/OBP-OIDC # OAuth2 Provider Selection (for well-known endpoint and token validation) -# Choose which OIDC provider to use: 'keycloak' or 'obp-oidc' -#oauth2.oidc_provider=obp-oidc +# Configure which OIDC providers should be advertised by the /well-known endpoint. +# +# This property accepts a comma-separated, case-insensitive list of providers. +# Available values: +# - keycloak → advertise Keycloak well-known URL +# - obp-oidc → advertise OBP-OIDC well-known URL +# +# Special cases: +# - (property missing) → show nothing +# - "" (empty string) → show all available providers +# - none → show nothing +# +# Examples: +# oauth2.oidc_provider=keycloak +# oauth2.oidc_provider=obp-oidc,keycloak +# oauth2.oidc_provider= +# oauth2.oidc_provider=none +# +# Default: property missing (show nothing) +#oauth2.oidc_provider=obp-oidc,keycloak # OBP-OIDC OAuth2 Provider Settings #oauth2.obp_oidc.host=http://localhost:9000 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 c4ac897614..80a72c2567 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 @@ -53,7 +53,7 @@ import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ import com.openbankproject.commons.model.enums.{TransactionRequestStatus, _} import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.Full +import net.liftweb.common.{Empty, Full} import net.liftweb.http.rest.RestHelper import net.liftweb.json import net.liftweb.json.{Extraction, compactRender, parse, prettyRender} @@ -159,22 +159,55 @@ trait APIMethods510 { List(apiTagApi)) lazy val getOAuth2ServerWellKnown: OBPEndpoint = { - case "well-known" :: Nil JsonGet _ => { - cc => - implicit val ec = EndpointContext(Some(cc)) - for { - (_, callContext) <- anonymousAccess(cc) - } yield { - // Advertise the configured OIDC provider for this OBP-API instance - // Check if OBP-OIDC is configured, otherwise default to Keycloak - val oidcProvider = APIUtil.getPropsValue("oauth2.oidc_provider", "keycloak").toLowerCase match { - case "obp-oidc" | "obpoidc" => - WellKnownUriJsonV510("obp-oidc", OBPOIDC.wellKnownOpenidConfiguration.toURL.toString) - case _ => - WellKnownUriJsonV510("keycloak", Keycloak.wellKnownOpenidConfiguration.toURL.toString) - } - (WellKnownUrisJsonV510(List(oidcProvider)), HttpCode.`200`(callContext)) + case "well-known" :: Nil JsonGet _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- anonymousAccess(cc) + } yield { + // Read and normalize property + val providerPropBox = APIUtil.getPropsValue("oauth2.oidc_provider") + + // Define available providers + val availableProviders = Map( + "obp-oidc" -> WellKnownUriJsonV510("obp-oidc", OBPOIDC.wellKnownOpenidConfiguration.toURL.toString), + "keycloak" -> WellKnownUriJsonV510("keycloak", Keycloak.wellKnownOpenidConfiguration.toURL.toString) + ) + + // Resolve list of providers to show + val providersToShow: List[WellKnownUriJsonV510] = providerPropBox match { + case Empty => + // Property missing: show nothing + Nil + + case Full(value) if value.trim.isEmpty => + // Empty string: show all + availableProviders.values.toList + + case Full(value) => + val wanted = value + .split(",") + .map(_.trim.toLowerCase) + .filter(_.nonEmpty) + .toSet + + // Special case: "none" means show nothing + if (wanted.contains("none")) { + Nil + } else { + val (known, unknown) = wanted.partition(availableProviders.contains) + availableProviders + .filterKeys(known.contains) + .values + .toList + } + + case _ => + // Unexpected case, fallback to show nothing + Nil } + + (WellKnownUrisJsonV510(providersToShow), HttpCode.`200`(callContext)) + } } }