From c885ec9f2472bb08ba9dbe78937e3524a1e8340c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 28 Aug 2026 16:13:02 +0200 Subject: [PATCH 1/4] Adding Mobile Phone Number to Resource User --- .../scala/code/api/v7_0_0/Http4s700.scala | 143 ++++++++++++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 149 ++++++++++++++- .../code/model/dataAccess/ResourceUser.scala | 13 +- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 173 +++++++++++++++++- .../commons/model/UserModel.scala | 6 + 5 files changed, 470 insertions(+), 14 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 7486daecb2..3f0f9f944a 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -871,15 +871,18 @@ object Http4s700 { } lastActivityDate = userMetrics.headOption.map(_.getDate()) recentOperationIds = userMetrics.map(_.getImplementedByPartialFunction()).distinct.take(5) - } yield JSONFactory600.createUserInfoJsonV600( + } yield JSONFactory700.createUserInfoDetailJsonV700( user, - authUser.map(_.firstName.get).getOrElse(""), - authUser.map(_.lastName.get).getOrElse(""), - entitlements, - agreements, - isLocked, - lastActivityDate, - recentOperationIds + JSONFactory600.createUserInfoJsonV600( + user, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), + entitlements, + agreements, + isLocked, + lastActivityDate, + recentOperationIds + ) ) } } @@ -896,13 +899,135 @@ object Http4s700 { | |CanGetAnyUser entitlement is required.""", EmptyBody, - userInfoJsonV600, + JSONFactory700.userInfoDetailJsonV700Example, List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), apiTagUser :: Nil, Some(List(canGetAnyUser)), http4sPartialFunction = Some(getUserByUserId) ) + // Route: GET /obp/v7.0.0/users/current + // v7 signature change over v6: the response carries the user's own mobile phone + // fields (number, is_validated flag, validated date) stored on ResourceUser — + // distinct from the bank-scoped mobile_phone_number on Customer (KYC data). + val getCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + } yield { + val permissions = Views.views.vend.getPermissionForUser(user).toOption + val virtualRoleNames = + if (APIUtil.isSuperAdmin(user.userId)) JSONFactory200.superAdminVirtualRoles + else if (APIUtil.isOidcOperator(user.userId)) JSONFactory200.oidcOperatorVirtualRoles + else List.empty + val existingRoleNames = entitlements.map(_.roleName).toSet + val virtualEntitlements = virtualRoleNames.filterNot(existingRoleNames.contains).map { role => + new Entitlement { + def entitlementId = "" + def bankId = "" + def userId = user.userId + def roleName = role + def createdByProcess = + if (APIUtil.isSuperAdmin(user.userId)) "super_admin_user_ids" + else "oidc_operator_user_ids" + def entitlementRequestId: Option[String] = None + def groupId: Option[String] = None + def process: Option[String] = None + def grantedByUserId: Option[String] = None + } + } + val currentUser = UserV600(user, entitlements ::: virtualEntitlements, permissions) + val onBehalfOfUser = + if (cc.onBehalfOfUser.isDefined) { + val u = cc.onBehalfOfUser.toOption.get + val ents = Entitlement.entitlement.vend.getEntitlementsByUserId(u.userId) + .headOption.toList.flatten + val perms = Views.views.vend.getPermissionForUser(u).toOption + Some(UserV600(u, ents, perms)) + } else None + JSONFactory700.createUserJsonV700(currentUser, onBehalfOfUser) + } + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getCurrentUser), + "GET", + "/users/current", + "Get User (Current)", + """Get the logged in user. + | + |In v7.0.0 the response includes the user's own mobile phone fields: + |`mobile_phone_number`, `mobile_phone_number_is_validated` and + |`mobile_phone_number_validated_date`. These belong to the authenticated + |user (global across banks) and are distinct from the bank-scoped + |`mobile_phone_number` on Customer, which is KYC data of a legal entity. + | + |Authentication is required.""".stripMargin, + EmptyBody, + JSONFactory700.userJsonV700Example, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagUser :: Nil, + None, + http4sPartialFunction = Some(getCurrentUser) + ) + + // Route: PUT /obp/v7.0.0/my/user/mobile-phone-number + val updateMyMobilePhoneNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "user" / "mobile-phone-number" => + EndpointHelpers.withUserAndBody[JSONFactory700.PutMyMobilePhoneNumberJsonV700, JSONFactory700.MyMobilePhoneNumberJsonV700](req) { (user, body, cc) => + for { + _ <- Helper.booleanToFuture(InvalidPhoneNumber, cc = Some(cc)) { + body.mobile_phone_number.matches("""\+?[0-9\-\s().]{5,50}""") + } + resourceUser <- Future { + UserVend.users.vend.getResourceUserByResourceUserId(user.userPrimaryKey.value) + } map { x => unboxFullOrFail(x, Some(cc), UserNotFoundByUserId, 404) } + updated <- Future { + val numberChanged = !resourceUser.mobilePhoneNumber.contains(body.mobile_phone_number) + resourceUser.MobilePhoneNumber(body.mobile_phone_number) + // a changed number is unverified: reset the flag, but keep + // MobilePhoneNumberValidatedDate as the audit trail of the last + // successful validation + if (numberChanged) resourceUser.MobilePhoneNumberIsValidated(false) + resourceUser.saveMe() + } + } yield JSONFactory700.MyMobilePhoneNumberJsonV700( + mobile_phone_number = updated.mobilePhoneNumber, + mobile_phone_number_is_validated = updated.mobilePhoneNumberIsValidated, + mobile_phone_number_validated_date = updated.mobilePhoneNumberValidatedDate + ) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(updateMyMobilePhoneNumber), + "PUT", + "/my/user/mobile-phone-number", + "Update My Mobile Phone Number", + """Set or update the mobile phone number of the currently authenticated user. + | + |This number belongs to the authenticated user (global across banks) and is + |distinct from the bank-scoped `mobile_phone_number` on Customer, which is + |KYC data of a legal entity. + | + |Setting a different number resets `mobile_phone_number_is_validated` to + |`false`. `mobile_phone_number_validated_date` is left untouched: it is the + |audit trail of the last successful validation and is only written by the + |validation flow. + | + |Authentication is required.""".stripMargin, + JSONFactory700.putMyMobilePhoneNumberJsonV700Example, + JSONFactory700.myMobilePhoneNumberJsonV700Example, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidPhoneNumber, UnknownError), + apiTagUser :: Nil, + None, + http4sPartialFunction = Some(updateMyMobilePhoneNumber) + ) + // ── Trading Endpoints ────────────────────────────────────────────────── // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 44ced1f6ea..7c4536ccc1 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -1,10 +1,13 @@ package code.api.v7_0_0 import code.api.Constant -import code.api.util.{APIUtil, CallContext} +import code.api.util.{APIUtil, CallContext, ExampleValue} import code.api.util.ErrorMessages import code.api.util.ErrorMessages.MandatoryPropertyIsNotSet -import code.api.v4_0_0.{EnergySource400, HostedAt400, HostedBy400, PostSimpleCounterpartyJson400} +import code.api.v2_0_0.EntitlementJSONs +import code.api.v3_0_0.{UserJsonV300, ViewsJSON300} +import code.api.v4_0_0.{EnergySource400, HostedAt400, HostedBy400, PostSimpleCounterpartyJson400, UserAgreementJson} +import code.api.v6_0_0.{EntitlementsJsonV600, JSONFactory600, UserInfoDetailJsonV600, UserV600} import code.bankconnectors.Connector import code.customer.CustomerX import code.metrics.{MappedMetric, MetricArchive, MetricsArchiveRun, MetricsProps} @@ -1477,6 +1480,148 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { sca_enabled = true ) + // ─── User JSON — v7 adds the user's own OBP-verified mobile phone fields ─────── + // Distinct from Customer.mobile_phone_number (bank-scoped KYC data): this is the + // authenticated person's number, global across banks, stored on ResourceUser. + // The validated flag is separate from the validated date so it can be reset + // (re-verification policy, suspected SIM swap) without losing the audit trail; + // the date is set only on successful validation, so it always means "last time + // this number passed verification". + + case class UserJsonV700( + user_id: String, + email: String, + provider_id: String, + provider: String, + username: String, + mobile_phone_number: Option[String], + mobile_phone_number_is_validated: Option[Boolean], + mobile_phone_number_validated_date: Option[Date], + entitlements: EntitlementsJsonV600, + views: Option[ViewsJSON300], + on_behalf_of: Option[UserJsonV300] + ) + + case class UserInfoDetailJsonV700( + user_id: String, + email: String, + provider_id: String, + provider: String, + username: String, + first_name: String, + last_name: String, + mobile_phone_number: Option[String], + mobile_phone_number_is_validated: Option[Boolean], + mobile_phone_number_validated_date: Option[Date], + entitlements: EntitlementJSONs, + views: Option[ViewsJSON300], + agreements: Option[List[UserAgreementJson]], + is_deleted: Boolean, + last_marketing_agreement_signed_date: Option[Date], + is_locked: Boolean, + created_date: Option[Date], + updated_date: Option[Date], + email_validated: Option[Boolean], + last_used_locale: Option[String], + last_activity_date: Option[Date], + recent_operation_ids: List[String] + ) + + def createUserJsonV700(currentUser: UserV600, onBehalfOfUser: Option[UserV600]): UserJsonV700 = { + val v600 = JSONFactory600.createUserInfoJSON(currentUser, onBehalfOfUser) + UserJsonV700( + user_id = v600.user_id, + email = v600.email, + provider_id = v600.provider_id, + provider = v600.provider, + username = v600.username, + mobile_phone_number = currentUser.user.mobilePhoneNumber, + mobile_phone_number_is_validated = currentUser.user.mobilePhoneNumberIsValidated, + mobile_phone_number_validated_date = currentUser.user.mobilePhoneNumberValidatedDate, + entitlements = v600.entitlements, + views = v600.views, + on_behalf_of = v600.on_behalf_of + ) + } + + private def toUserInfoDetailJsonV700( + v600: UserInfoDetailJsonV600, + mobilePhoneNumber: Option[String], + mobilePhoneNumberIsValidated: Option[Boolean], + mobilePhoneNumberValidatedDate: Option[Date] + ): UserInfoDetailJsonV700 = + UserInfoDetailJsonV700( + user_id = v600.user_id, + email = v600.email, + provider_id = v600.provider_id, + provider = v600.provider, + username = v600.username, + first_name = v600.first_name, + last_name = v600.last_name, + mobile_phone_number = mobilePhoneNumber, + mobile_phone_number_is_validated = mobilePhoneNumberIsValidated, + mobile_phone_number_validated_date = mobilePhoneNumberValidatedDate, + entitlements = v600.entitlements, + views = v600.views, + agreements = v600.agreements, + is_deleted = v600.is_deleted, + last_marketing_agreement_signed_date = v600.last_marketing_agreement_signed_date, + is_locked = v600.is_locked, + created_date = v600.created_date, + updated_date = v600.updated_date, + email_validated = v600.email_validated, + last_used_locale = v600.last_used_locale, + last_activity_date = v600.last_activity_date, + recent_operation_ids = v600.recent_operation_ids + ) + + def createUserInfoDetailJsonV700(user: User, v600: UserInfoDetailJsonV600): UserInfoDetailJsonV700 = + toUserInfoDetailJsonV700( + v600, + user.mobilePhoneNumber, + user.mobilePhoneNumberIsValidated, + user.mobilePhoneNumberValidatedDate + ) + + lazy val userJsonV700Example = UserJsonV700( + user_id = ExampleValue.userIdExample.value, + email = ExampleValue.emailExample.value, + provider_id = ExampleValue.providerIdValueExample.value, + provider = ExampleValue.providerValueExample.value, + username = ExampleValue.usernameExample.value, + mobile_phone_number = Some(ExampleValue.mobileNumberExample.value), + mobile_phone_number_is_validated = Some(true), + mobile_phone_number_validated_date = Some(APIUtil.DateWithSecondsExampleObject), + entitlements = EntitlementsJsonV600(Nil), + views = None, + on_behalf_of = None + ) + + lazy val userInfoDetailJsonV700Example = toUserInfoDetailJsonV700( + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.userInfoDetailJsonV600, + Some(ExampleValue.mobileNumberExample.value), + Some(true), + Some(APIUtil.DateWithSecondsExampleObject) + ) + + case class PutMyMobilePhoneNumberJsonV700(mobile_phone_number: String) + + case class MyMobilePhoneNumberJsonV700( + mobile_phone_number: Option[String], + mobile_phone_number_is_validated: Option[Boolean], + mobile_phone_number_validated_date: Option[Date] + ) + + lazy val putMyMobilePhoneNumberJsonV700Example = + PutMyMobilePhoneNumberJsonV700(ExampleValue.mobileNumberExample.value) + + // a freshly set number is unverified: flag false, no validated date yet + lazy val myMobilePhoneNumberJsonV700Example = MyMobilePhoneNumberJsonV700( + mobile_phone_number = Some(ExampleValue.mobileNumberExample.value), + mobile_phone_number_is_validated = Some(false), + mobile_phone_number_validated_date = None + ) + // ─── Password policy — published so clients can validate locally before user creation / // password reset. The structured fields are the normative contract; `regex` is a convenience // written in the portable subset that behaves identically in Java, JavaScript and Python. diff --git a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala index 0aa1c6db15..5290dd7487 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala @@ -101,7 +101,15 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa object PrincipalUserId extends MappedString(this, 100) { override def defaultValue = null } - + // Deliberately NOT unique — several users may share a number + object MobilePhoneNumber extends MappedString(this, 50) { + override def defaultValue = null + } + object MobilePhoneNumberIsValidated extends MappedBoolean(this) { + override def defaultValue = false + } + object MobilePhoneNumberValidatedDate extends MappedDateTime(this) + def emailAddress = { val e = email.get if(e != null) e else "" @@ -133,6 +141,9 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa override def lastUsedLocale: Option[String] = if(LastUsedLocale.get == null) None else Some(LastUsedLocale.get) // null --> None override def isNaturalPerson: Boolean = IsNaturalPerson.get override def principalUserIdOption: Option[String] = if(PrincipalUserId.get == null) None else if (PrincipalUserId.get.isEmpty) None else Some(PrincipalUserId.get) + override def mobilePhoneNumber: Option[String] = if(MobilePhoneNumber.get == null) None else if (MobilePhoneNumber.get.isEmpty) None else Some(MobilePhoneNumber.get) + override def mobilePhoneNumberIsValidated: Option[Boolean] = if(MobilePhoneNumberIsValidated.jdbcFriendly(MobilePhoneNumberIsValidated.calcFieldName) == null) None else Some(MobilePhoneNumberIsValidated.get) // null --> None + override def mobilePhoneNumberValidatedDate: Option[Date] = if(MobilePhoneNumberValidatedDate.get == null) None else Some(MobilePhoneNumberValidatedDate.get) } object ResourceUser extends ResourceUser with LongKeyedMetaMapper[ResourceUser]{ diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 5dbecd26a8..45569068cd 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -8,7 +8,7 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureAmqpBankBroker, canGetMessageOutbox, canRetryMessageOutbox, canSettleOpenCorridor, canCreateAccount, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -1083,8 +1083,15 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } scenario("Return 200 with user fields when authenticated with canGetAnyUser role", Http4s700RoutesTag) { - Given("canGetAnyUser role granted to resourceUser1") + Given("canGetAnyUser role granted to resourceUser1, who has a mobile phone number") addEntitlement("", resourceUser1.userId, canGetAnyUser.toString) + code.model.dataAccess.ResourceUser.find( + By(code.model.dataAccess.ResourceUser.userId_, resourceUser1.userId) + ).openOrThrowException("resourceUser1 must exist") + .MobilePhoneNumber("+49123456789") + .MobilePhoneNumberIsValidated(true) + .MobilePhoneNumberValidatedDate(new Date()) + .save When(s"GET /obp/v7.0.0/users/user-id/${resourceUser1.userId} with DirectLogin header") val headers = Map("DirectLogin" -> s"token=${token1.value}") @@ -1102,6 +1109,12 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { m.keys should contain("username") m.keys should contain("email") m.keys should contain("entitlements") + m.get("mobile_phone_number") shouldBe Some(JString("+49123456789")) + m.get("mobile_phone_number_is_validated") shouldBe Some(JBool(true)) + m.get("mobile_phone_number_validated_date") match { + case Some(JString(_)) => succeed + case other => fail(s"Expected mobile_phone_number_validated_date as date string, got $other") + } case _ => fail("Expected JSON object for getUserByUserId") } } @@ -1127,6 +1140,162 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── getCurrentUser (v7 native — adds the user's mobile phone fields) ───────── + + feature("Http4s700 getCurrentUser endpoint") { + + scenario("Reject unauthenticated access to /users/current", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/users/current with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/users/current") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 with mobile phone fields served natively by v7", Http4s700RoutesTag) { + Given("resourceUser1 has a validated mobile phone number") + val ru = code.model.dataAccess.ResourceUser.find( + By(code.model.dataAccess.ResourceUser.userId_, resourceUser1.userId) + ).openOrThrowException("resourceUser1 must exist") + ru.MobilePhoneNumber("+49123456789") + .MobilePhoneNumberIsValidated(true) + .MobilePhoneNumberValidatedDate(new Date()) + .save + + When("GET /obp/v7.0.0/users/current with DirectLogin header") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, respHeaders) = makeHttpRequest("/obp/v7.0.0/users/current", headers) + + Then("Response is 200, served by v7 (no version-served fallback header), with the mobile fields") + statusCode shouldBe 200 + hasHeader(respHeaders, "X-OBP-Version-Served") shouldBe false + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.get("user_id") shouldBe Some(JString(resourceUser1.userId)) + m.get("mobile_phone_number") shouldBe Some(JString("+49123456789")) + m.get("mobile_phone_number_is_validated") shouldBe Some(JBool(true)) + m.get("mobile_phone_number_validated_date") match { + case Some(JString(_)) => succeed + case other => fail(s"Expected mobile_phone_number_validated_date as date string, got $other") + } + case _ => fail("Expected JSON object for getCurrentUser") + } + } + } + + // ─── updateMyMobilePhoneNumber ──────────────────────────────────────────────── + + feature("Http4s700 updateMyMobilePhoneNumber endpoint") { + + scenario("Reject unauthenticated PUT to /my/user/mobile-phone-number", Http4s700RoutesTag) { + Given("PUT /obp/v7.0.0/my/user/mobile-phone-number with no auth headers") + val body = """{"mobile_phone_number":"+49123456789"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/my/user/mobile-phone-number", body) + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Setting a different number resets the validated flag but keeps the validated date", Http4s700RoutesTag) { + Given("resourceUser1 has a validated mobile phone number") + val ru = code.model.dataAccess.ResourceUser.find( + By(code.model.dataAccess.ResourceUser.userId_, resourceUser1.userId) + ).openOrThrowException("resourceUser1 must exist") + ru.MobilePhoneNumber("+49123456789") + .MobilePhoneNumberIsValidated(true) + .MobilePhoneNumberValidatedDate(new Date()) + .save + + When("PUT /obp/v7.0.0/my/user/mobile-phone-number with a new number") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = """{"mobile_phone_number":"+49 170 5556677"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/my/user/mobile-phone-number", body, headers) + + Then("Response is 200 with the new number, is_validated false, validated date preserved") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.get("mobile_phone_number") shouldBe Some(JString("+49 170 5556677")) + m.get("mobile_phone_number_is_validated") shouldBe Some(JBool(false)) + m.get("mobile_phone_number_validated_date") match { + case Some(JString(_)) => succeed + case other => fail(s"Expected preserved validated date, got $other") + } + case _ => fail("Expected JSON object for updateMyMobilePhoneNumber") + } + + And("the database reflects the new number with the flag reset") + val reloaded = code.model.dataAccess.ResourceUser.find( + By(code.model.dataAccess.ResourceUser.userId_, resourceUser1.userId) + ).openOrThrowException("resourceUser1 must exist") + reloaded.mobilePhoneNumber shouldBe Some("+49 170 5556677") + reloaded.mobilePhoneNumberIsValidated shouldBe Some(false) + reloaded.mobilePhoneNumberValidatedDate.isDefined shouldBe true + } + + scenario("Re-submitting the same number keeps the validated flag", Http4s700RoutesTag) { + Given("resourceUser1 has a validated mobile phone number") + val ru = code.model.dataAccess.ResourceUser.find( + By(code.model.dataAccess.ResourceUser.userId_, resourceUser1.userId) + ).openOrThrowException("resourceUser1 must exist") + ru.MobilePhoneNumber("+49123456789") + .MobilePhoneNumberIsValidated(true) + .MobilePhoneNumberValidatedDate(new Date()) + .save + + When("PUT /obp/v7.0.0/my/user/mobile-phone-number with the same number") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = """{"mobile_phone_number":"+49123456789"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/my/user/mobile-phone-number", body, headers) + + Then("Response is 200 and the number is still validated") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.get("mobile_phone_number") shouldBe Some(JString("+49123456789")) + m.get("mobile_phone_number_is_validated") shouldBe Some(JBool(true)) + case _ => fail("Expected JSON object for updateMyMobilePhoneNumber") + } + } + + scenario("Reject an invalid phone number with 400", Http4s700RoutesTag) { + When("PUT /obp/v7.0.0/my/user/mobile-phone-number with a non-numeric value") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = """{"mobile_phone_number":"not-a-phone-number"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/my/user/mobile-phone-number", body, headers) + + Then("Response is 400 with InvalidPhoneNumber message") + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(InvalidPhoneNumber) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + feature("Http4s700 createOrganisation endpoint") { scenario("Reject unauthenticated POST to /organisations", Http4s700RoutesTag) { diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala index 1bc7702515..e2f35935ed 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala @@ -73,6 +73,12 @@ trait User { def lastUsedLocale: Option[String] = None def isNaturalPerson: Boolean = true def principalUserIdOption: Option[String] = None + //the user's own OBP-verified mobile, global across banks — distinct from Customer.mobileNumber which is bank-scoped KYC data + def mobilePhoneNumber: Option[String] = None + //kept separate from the date so it can be reset without losing the audit trail + def mobilePhoneNumberIsValidated: Option[Boolean] = None + //set only on successful validation: always means "last time this number passed verification" + def mobilePhoneNumberValidatedDate: Option[Date] = None } case class UserPrimaryKey(val value : Long) { From b3515dc0abdff9acc4c99da2a3c9e87c262dcdd2 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 28 Aug 2026 18:47:51 +0200 Subject: [PATCH 2/4] FAPI token-binding check, GET my metrics --- .../resources/props/sample.props.template | 24 +++ obp-api/src/main/scala/code/api/OAuth2.scala | 12 ++ .../scala/code/api/util/ErrorMessages.scala | 6 + .../scala/code/api/util/TokenBinding.scala | 130 +++++++++++++++ .../scala/code/api/v6_0_0/Http4s600.scala | 22 +-- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 7 + .../scala/code/api/v7_0_0/Http4s700.scala | 86 +++++++++- .../main/scala/code/metrics/APIMetrics.scala | 38 ++++- .../code/api/util/TokenBindingTest.scala | 154 ++++++++++++++++++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 106 +++++++++++- 10 files changed, 563 insertions(+), 22 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/TokenBinding.scala create mode 100644 obp-api/src/test/scala/code/api/util/TokenBindingTest.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 55608d6706..141dfb17b7 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1066,6 +1066,30 @@ display_internal_errors=false # After setting the above and restarting the server curl -s http://localhost:8080/obp/v5.1.0/well-known # should advertise obp-oidc + +# Sender-constrained (certificate-bound) access tokens - FAPI / RFC 8705. +# A FAPI-grade authorization server (e.g. Keycloak with "certificate-bound access +# tokens" enabled per client) stamps cnf.x5t#S256 (the SHA-256 thumbprint of the +# client certificate) into the access token. This prop controls whether OBP, as the +# resource server, verifies that claim against the certificate the caller actually +# presented (as resolved by PeerTrust: the direct TLS peer, or one forwarded by a +# trusted proxy - see mtls.* props). A stolen bearer token then cannot be replayed +# from a different TLS client. +# +# Modes (rollout ladder - move down as your estate migrates): +# NONE (default) no checking; behaviour identical to before this prop existed +# MONITOR check tokens that carry cnf.x5t#S256, log mismatches, never reject +# ENFORCE reject bound tokens that do not match (or arrive without a client +# certificate); tokens WITHOUT a cnf claim still pass, so first-party +# apps on plain bearer tokens keep working while TPP clients are bound +# REQUIRED every OAuth2 token (access and id) must be bound and match - full FAPI +# posture, for instances dedicated to the TPP channel +# +# Which clients receive bound tokens is decided per client in the authorization +# server, so app-by-app migration control lives there; this prop is the server-side +# gate. DPoP (cnf.jkt) is not yet supported. An invalid value logs an error and +# behaves as NONE. +#oauth2.token_binding.mode=NONE # ----------------------------------------------------------------------------------- diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 7164a2ba95..80b8e452ba 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -538,6 +538,12 @@ object OAuth2Login extends MdcLoggable { validateIdToken(token) match { case Full(_) => logger.debug("applyIdTokenRules - ID token validation successful") + // Also enforced on the ID-token login path: in REQUIRED mode an unbound ID token + // must not be a bypass around certificate-bound access tokens. + TokenBinding.verifyTokenBinding(token, cc) match { + case failure: Failure => return (failure, Some(cc)) + case _ => // binding ok, or checking not enabled + } validateAudience(token) match { case Full(_) => val user = getOrCreateResourceUser(token) @@ -586,6 +592,12 @@ object OAuth2Login extends MdcLoggable { validateAccessToken(token) match { case Full(_) => + // FAPI / RFC 8705 sender-constrained token check (oauth2.token_binding.mode). + // Runs after signature validation so the cnf claim can be trusted. + TokenBinding.verifyTokenBinding(token, cc) match { + case failure: Failure => return (failure, Some(cc)) + case _ => // binding ok, or checking not enabled + } validateAudience(token) match { case Full(_) => val user = getOrCreateResourceUser(token) diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 33040e83d4..d833b1fd14 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -148,6 +148,8 @@ object ErrorMessages { val InvalidSignalChannelName = "OBP-10057: Invalid Signal Channel name. " + "Signal Channel names must use only alphanumeric characters, dots, hyphens, and underscores, " + "and be between 1 and 128 characters long." + val UserFilterParametersNotSupported = "OBP-10058: User identity filter parameters (user_id, username, email, provider_provider_id, anon) " + + "are not supported on this endpoint. It only ever returns the logged in user's own records. " @@ -324,6 +326,10 @@ object ErrorMessages { val DuplicateUsername = "OBP-20258: Duplicate Username. Cannot create Username because it already exists. " val ExternalUserCheckFailed = "OBP-20259: Could not check username uniqueness against the external provider. The Connector or Adapter may not be running. " + val Oauth2TokenBindingCertificateMissing = "OBP-20260: The access token is certificate-bound (cnf.x5t#S256) but no client certificate was presented with the request. " + val Oauth2TokenBindingCertificateMismatch = "OBP-20261: The presented client certificate does not match the certificate binding (cnf.x5t#S256) of the access token. " + val Oauth2TokenBindingRequired = "OBP-20262: This instance requires certificate-bound access tokens (oauth2.token_binding.mode=REQUIRED) but the token carries no cnf.x5t#S256 claim. " + // X.509 val X509GeneralError = "OBP-20300: PEM Encoded Certificate issue." diff --git a/obp-api/src/main/scala/code/api/util/TokenBinding.scala b/obp-api/src/main/scala/code/api/util/TokenBinding.scala new file mode 100644 index 0000000000..19f3658654 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/TokenBinding.scala @@ -0,0 +1,130 @@ +package code.api.util + +import java.security.MessageDigest +import java.security.cert.X509Certificate +import java.util.Base64 + +import code.api.util.APIUtil.`getPSD2-CERT` +import code.api.util.ErrorMessages._ +import code.util.Helper.MdcLoggable +import com.nimbusds.jwt.SignedJWT +import net.liftweb.common.{Box, Failure, Full} +import net.liftweb.util.Helpers.tryo + +/** + * Sender-constrained (certificate-bound) access token verification — RFC 8705, as required + * by FAPI at the resource server. A FAPI-grade authorization server (e.g. Keycloak with + * certificate-bound access tokens enabled per client) stamps the confirmation claim + * `cnf.x5t#S256` = base64url(SHA-256(DER of the client certificate)) into the access token. + * This object compares that claim against the certificate the caller actually presented, + * so a stolen bearer token cannot be replayed from a different TLS client. + * + * The caller certificate is whatever [[PeerTrust]] resolved for this request (the direct TLS + * peer, or one forwarded by a trusted proxy) — delivered via the PSD2-CERT request header, the + * same channel the PSD2 certificate checks use. The check therefore inherits the mtls.* trust + * configuration and never trusts an unverified forwarded header. + * + * Gated by the oauth2.token_binding.mode Props value: + * - NONE (default): no checking — existing deployments are untouched. + * - MONITOR: check tokens that carry cnf.x5t#S256 and log mismatches, but never reject. + * - ENFORCE: reject bound tokens that do not match (or arrive with no certificate); + * tokens without a cnf claim still pass, so a mixed estate can migrate app by app. + * - REQUIRED: every OAuth2 token must be bound and match — full FAPI posture. + * + * DPoP (cnf.jkt, the FAPI 2.0 alternative binding) is not yet supported. + */ +object TokenBinding extends MdcLoggable { + + final val ModePropsName = "oauth2.token_binding.mode" + + object Mode extends Enumeration { + val NONE, MONITOR, ENFORCE, REQUIRED = Value + } + + /** + * The configured mode. An unrecognised value cannot be allowed to silently harden or soften + * the instance, so it logs loudly and behaves as NONE — the same behaviour as before the + * prop existed. + */ + def configuredMode: Mode.Value = { + val raw = APIUtil.getPropsValue(ModePropsName, Mode.NONE.toString).trim.toUpperCase + Mode.values.find(_.toString == raw).getOrElse { + logger.error(s"$ModePropsName has invalid value '$raw' (valid values: ${Mode.values.mkString(", ")}). " + + s"Falling back to ${Mode.NONE} — token binding is NOT being checked.") + Mode.NONE + } + } + + /** base64url without padding of SHA-256 over the certificate's DER encoding (x5t#S256). */ + def x5tS256(certificate: X509Certificate): String = + Base64.getUrlEncoder.withoutPadding.encodeToString( + MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded)) + + /** + * The cnf.x5t#S256 claim of a JWT, if present. The token's signature must already have been + * validated by the caller — this only parses claims. + */ + def cnfX5tS256(jwtToken: String): Option[String] = + tryo(SignedJWT.parse(jwtToken).getJWTClaimsSet.getJSONObjectClaim("cnf")).toOption + .flatMap(Option(_)) + .flatMap(cnf => Option(cnf.get("x5t#S256"))) + .map(_.toString) + .filter(_.nonEmpty) + + /** + * The pure decision — mode and inputs passed explicitly so it is testable without Props or + * a server. Returns Full(()) when the request may proceed. + */ + def verify( + mode: Mode.Value, + cnfThumbprint: Option[String], + callerCertificate: Option[X509Certificate], + logContext: => String + ): Box[Unit] = { + (mode, cnfThumbprint, callerCertificate) match { + case (Mode.NONE, _, _) => + Full(()) + case (Mode.REQUIRED, None, _) => + Failure(Oauth2TokenBindingRequired) + case (_, None, _) => // MONITOR / ENFORCE: an unbound token passes untouched + Full(()) + case (Mode.MONITOR, Some(_), None) => + logger.warn(s"TOKEN BINDING MONITOR: token is certificate-bound (cnf.x5t#S256) " + + s"but no client certificate was presented. $logContext") + Full(()) + case (_, Some(_), None) => // ENFORCE / REQUIRED + Failure(Oauth2TokenBindingCertificateMissing) + case (m, Some(expected), Some(certificate)) => + val presented = x5tS256(certificate) + // constant-time comparison — thumbprints are secrets-adjacent + val matches = MessageDigest.isEqual(expected.getBytes("UTF-8"), presented.getBytes("UTF-8")) + if (matches) Full(()) + else if (m == Mode.MONITOR) { + logger.warn(s"TOKEN BINDING MONITOR: thumbprint mismatch — token cnf.x5t#S256=$expected, " + + s"presented certificate=$presented. $logContext") + Full(()) + } else { + Failure(Oauth2TokenBindingCertificateMismatch) + } + } + } + + /** + * Props-driven entry point for the OAuth2 login path. Call after the token's signature has + * been validated. + */ + def verifyTokenBinding(jwtToken: String, cc: CallContext): Box[Unit] = { + val mode = configuredMode + if (mode == Mode.NONE) Full(()) + else { + val callerCertificate: Option[X509Certificate] = `getPSD2-CERT`(cc.requestHeaders) + .flatMap(pem => tryo(BerlinGroupSigning.parseCertificate(pem)).toOption) + verify( + mode, + cnfX5tS256(jwtToken), + callerCertificate, + s"url=${cc.url} correlationId=${cc.correlationId}" + ) + } + } +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 3b91ef8fff..22a584ee1c 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -637,30 +637,14 @@ object Http4s600 { } - // Inject default from_date so metrics queries don't hit all rows since epoch. - private def applyMetricsFromDateDefault(httpParams: List[HTTPParam]): List[HTTPParam] = { - val hasFromDate = httpParams.exists(p => p.name == "from_date" || p.name == "obp_from_date") - if (hasFromDate) httpParams - else { - val stableBoundary = APIUtil.getPropsAsIntValue("MappedMetrics.stable.boundary.seconds", 600) - val defaultFromDate = new java.util.Date(System.currentTimeMillis() - ((stableBoundary - 1) * 1000L)) - HTTPParam("from_date", List(APIUtil.DateWithMsFormat.format(defaultFromDate))) :: httpParams - } - } - // Route: GET /obp/v6.0.0/management/metrics lazy val getMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "metrics" => EndpointHelpers.withUser(req) { (_, cc) => for { httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) - (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture( - applyMetricsFromDateDefault(httpParams), cc.callContext) - metrics <- Future(APIMetrics.apiMetrics.vend.getAllMetrics(obpQueryParams)) - } yield { - val lookupMap = APIUtil.getAllResourceDocs.map(d => d.partialFunctionName -> d.operationId).toMap - JSONFactory600.createMetricsJsonV600(metrics, lookupMap) - } + (metrics, _) <- APIMetrics.getMetricsFromHttpParams(httpParams, cc.callContext) + } yield JSONFactory600.createMetricsJsonV600(metrics) } } @@ -681,7 +665,7 @@ object Http4s600 { else true } (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture( - applyMetricsFromDateDefault(httpParams), cc.callContext) + APIMetrics.applyMetricsFromDateDefault(httpParams), cc.callContext) aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, false) map { APIUtil.unboxFullOrFail(_, callContext, GetAggregateMetricsError) } 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 c8b2037cda..878c77d546 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 @@ -1739,6 +1739,13 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { MetricsJsonV600(metrics.map(createMetricJsonV600(_, lookupMap))) } + // Overload that builds the partialFunctionName -> operationId lookup itself — + // the shared path for endpoints returning raw metric rows. + def createMetricsJsonV600(metrics: List[code.metrics.APIMetric]): MetricsJsonV600 = { + val lookupMap = code.api.util.APIUtil.getAllResourceDocs.map(d => d.partialFunctionName -> d.operationId).toMap + createMetricsJsonV600(metrics, lookupMap) + } + def createBankJSON600( bank: Bank, attributes: List[BankAttributeTrait] = Nil diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 3f0f9f944a..d44242188b 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -45,7 +45,7 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} import code.loginattempts.LoginAttempt -import code.metrics.MappedMetric +import code.metrics.{APIMetrics, MappedMetric} import code.users.UserAgreementProvider import net.liftweb.common.Full import com.openbankproject.commons.util.JsonAliases.prettyRender @@ -1028,6 +1028,90 @@ object Http4s700 { http4sPartialFunction = Some(updateMyMobilePhoneNumber) ) + // Route: GET /obp/v7.0.0/my/metrics + // Same fetch path as GET /management/metrics (APIMetrics.getMetricsFromHttpParams) + // with the user filter locked to the logged-in user. + val getMyMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "metrics" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + // The caller may only ever see their own calls: identity filters are + // rejected outright rather than silently ignored. + identityParams = httpParams.map(_.name) + .filter(Set("user_id", "username", "email", "provider_provider_id", "anon").contains) + _ <- Helper.booleanToFuture( + s"$UserFilterParametersNotSupported Parameters found: [${identityParams.mkString(", ")}]", + cc = Some(cc)) { + identityParams.isEmpty + } + (metrics, _) <- APIMetrics.getMetricsFromHttpParams( + httpParams, cc.callContext, lockedUserId = Some(user.userId)) + } yield JSONFactory600.createMetricsJsonV600(metrics) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getMyMetrics), + "GET", + "/my/metrics", + "Get Metrics (My)", + s"""Get the API metrics rows of the currently authenticated user — a record of each REST API call this user has made. + | + |No role is required: this endpoint only ever returns the logged in user's own calls. + |The identity filter parameters accepted by `GET /management/metrics` (`user_id`, `username`, `email`, + |`provider_provider_id`, `anon`) are NOT supported here and are rejected with an error — + |the user filter is always the current user. + | + |**NOTE: Automatic from_date Default** + | + |If you do not provide a `from_date` parameter it is automatically set to a few minutes ago + |(now - ${(APIUtil.getPropsValue("MappedMetrics.stable.boundary.seconds", "600").toInt - 1) / 60} minutes). + |For historical queries, always explicitly specify your desired `from_date` — this also enables + |long-term caching of the result. + | + |The other filter and pagination parameters work as on `GET /management/metrics`: + | + |eg: /my/metrics?from_date=$DateWithMsExampleString&to_date=$DateWithMsExampleString&limit=50&offset=2 + | + |1 from_date e.g.:from_date=$DateWithMsExampleString + | + |2 to_date e.g.:to_date=$DateWithMsExampleString Defaults to a far future date i.e. ${APIUtil.ToDateInFuture} + | + |3 limit (for pagination: defaults to 50) eg:limit=200 + | + |4 offset (for pagination: zero index, defaults to 0) eg: offset=10 + | + |5 sort_by (defaults to date field) eg: sort_by=date + | + |6 direction (defaults to date desc) eg: direction=desc + | + |7 consumer_id (if null ignore) + | + |8 app_name (if null ignore) + | + |9 url (if null ignore) + | + |10 implemented_by_partial_function (if null ignore) + | + |11 implemented_in_version (if null ignore) + | + |12 verb (if null ignore) + | + |13 correlation_id (if null ignore) + | + |14 duration (if null ignore) - Returns calls where duration > specified value (in milliseconds). eg: duration=5000 + | + |Authentication is required.""".stripMargin, + EmptyBody, + metricsJsonV600, + List($AuthenticatedUserIsRequired, UserFilterParametersNotSupported, UnknownError), + apiTagMetric :: apiTagUser :: Nil, + None, + http4sPartialFunction = Some(getMyMetrics) + ) + // ── Trading Endpoints ────────────────────────────────────────────────── // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers diff --git a/obp-api/src/main/scala/code/metrics/APIMetrics.scala b/obp-api/src/main/scala/code/metrics/APIMetrics.scala index 706539d5bd..4a68def7bc 100644 --- a/obp-api/src/main/scala/code/metrics/APIMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/APIMetrics.scala @@ -1,7 +1,9 @@ package code.metrics import java.util.{Calendar, Date} -import code.api.util.{APIUtil, OBPQueryParam} +import code.api.util.{APIUtil, CallContext, OBPQueryParam} +import code.api.util.APIUtil.{HTTPParam, createQueriesByHttpParamsFuture} +import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.util.ApiVersion import net.liftweb.common.Box import net.liftweb.util.SimpleInjector @@ -35,6 +37,40 @@ object APIMetrics extends SimpleInjector { cal.getTime } + // Inject default from_date so metrics queries don't hit all rows since epoch. + // Shared by every endpoint that reads metrics (GET /management/metrics, + // GET /management/aggregate-metrics, GET /my/metrics, ...). + def applyMetricsFromDateDefault(httpParams: List[HTTPParam]): List[HTTPParam] = { + val hasFromDate = httpParams.exists(p => p.name == "from_date" || p.name == "obp_from_date") + if (hasFromDate) httpParams + else { + val stableBoundary = APIUtil.getPropsAsIntValue("MappedMetrics.stable.boundary.seconds", 600) + val defaultFromDate = new Date(System.currentTimeMillis() - ((stableBoundary - 1) * 1000L)) + HTTPParam("from_date", List(APIUtil.DateWithMsFormat.format(defaultFromDate))) :: httpParams + } + } + + // One shared fetch path for metrics-reading endpoints: builds OBPQueryParams + // from the http params (with the from_date default applied) and runs the query. + // lockedUserId pins the user filter server-side (for self-service endpoints + // like GET /my/metrics); when set it overrides anything in httpParams. + def getMetricsFromHttpParams( + httpParams: List[HTTPParam], + callContext: Option[CallContext], + lockedUserId: Option[String] = None + ): Future[(List[APIMetric], Option[CallContext])] = { + val effectiveParams = lockedUserId match { + case Some(userId) => + httpParams.filterNot(_.name == "user_id") :+ HTTPParam("user_id", List(userId)) + case None => httpParams + } + for { + (obpQueryParams, cc) <- createQueriesByHttpParamsFuture( + applyMetricsFromDateDefault(effectiveParams), callContext) + metrics <- Future(apiMetrics.vend.getAllMetrics(obpQueryParams)) + } yield (metrics, cc) + } + } trait APIMetrics { diff --git a/obp-api/src/test/scala/code/api/util/TokenBindingTest.scala b/obp-api/src/test/scala/code/api/util/TokenBindingTest.scala new file mode 100644 index 0000000000..aeaa383b21 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/TokenBindingTest.scala @@ -0,0 +1,154 @@ +package code.api.util + +import java.security.cert.X509Certificate + +import code.api.util.ErrorMessages.{Oauth2TokenBindingCertificateMismatch, Oauth2TokenBindingCertificateMissing, Oauth2TokenBindingRequired} +import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert +import code.api.util.TokenBinding.Mode +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import net.liftweb.common.{Failure, Full} +import org.scalatest.{FlatSpec, Matchers} + +/** + * Pure tests for the FAPI / RFC 8705 sender-constrained token decision: no server, no props, + * no TLS handshake — the mode and both inputs are passed explicitly, exactly so that every + * row of the decision table can be exercised here. + */ +class TokenBindingTest extends FlatSpec with Matchers { + + private def certFor(cn: String): X509Certificate = + generateSelfSignedCert(cn)._2.asInstanceOf[X509Certificate] + + private val boundCert = certFor("bound-tpp-client") + private val otherCert = certFor("some-other-client") + private val boundThumbprint = TokenBinding.x5tS256(boundCert) + + private val hmacSecret = "0123456789abcdef0123456789abcdef" // 32 bytes for HS256 + + private def signedJwt(claims: JWTClaimsSet): String = { + val jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims) + jwt.sign(new MACSigner(hmacSecret)) + jwt.serialize() + } + + private def tokenWithCnf(thumbprint: String): String = { + val cnf = new java.util.HashMap[String, Object]() + cnf.put("x5t#S256", thumbprint) + signedJwt(new JWTClaimsSet.Builder().subject("tpp-app").claim("cnf", cnf).build()) + } + + private val tokenWithoutCnf: String = + signedJwt(new JWTClaimsSet.Builder().subject("plain-app").build()) + + behavior of "x5tS256" + + it should "produce a 43-character base64url thumbprint without padding" in { + boundThumbprint should have length 43 + boundThumbprint should not include "=" + boundThumbprint should not include "+" + boundThumbprint should not include "/" + } + + it should "be stable for the same certificate and differ between certificates" in { + TokenBinding.x5tS256(boundCert) shouldBe boundThumbprint + TokenBinding.x5tS256(otherCert) should not be boundThumbprint + } + + behavior of "cnfX5tS256" + + it should "extract the thumbprint from a token carrying cnf.x5t#S256" in { + TokenBinding.cnfX5tS256(tokenWithCnf(boundThumbprint)) shouldBe Some(boundThumbprint) + } + + it should "return None for a token without a cnf claim" in { + TokenBinding.cnfX5tS256(tokenWithoutCnf) shouldBe None + } + + it should "return None for a string that is not a JWT" in { + TokenBinding.cnfX5tS256("not-a-jwt") shouldBe None + } + + behavior of "verify in NONE mode" + + it should "pass everything, even a mismatched bound token" in { + TokenBinding.verify(Mode.NONE, Some(boundThumbprint), Some(otherCert), "test") shouldBe Full(()) + TokenBinding.verify(Mode.NONE, None, None, "test") shouldBe Full(()) + } + + behavior of "verify in MONITOR mode" + + it should "pass an unbound token" in { + TokenBinding.verify(Mode.MONITOR, None, None, "test") shouldBe Full(()) + } + + it should "pass a matching bound token" in { + TokenBinding.verify(Mode.MONITOR, Some(boundThumbprint), Some(boundCert), "test") shouldBe Full(()) + } + + it should "pass (but only log) a mismatched bound token" in { + TokenBinding.verify(Mode.MONITOR, Some(boundThumbprint), Some(otherCert), "test") shouldBe Full(()) + } + + it should "pass (but only log) a bound token with no certificate presented" in { + TokenBinding.verify(Mode.MONITOR, Some(boundThumbprint), None, "test") shouldBe Full(()) + } + + behavior of "verify in ENFORCE mode" + + it should "pass an unbound token" in { + TokenBinding.verify(Mode.ENFORCE, None, None, "test") shouldBe Full(()) + } + + it should "pass a matching bound token" in { + TokenBinding.verify(Mode.ENFORCE, Some(boundThumbprint), Some(boundCert), "test") shouldBe Full(()) + } + + it should "reject a mismatched bound token" in { + TokenBinding.verify(Mode.ENFORCE, Some(boundThumbprint), Some(otherCert), "test") match { + case Failure(msg, _, _) => msg should include(Oauth2TokenBindingCertificateMismatch) + case other => fail(s"Expected Failure, got $other") + } + } + + it should "reject a bound token with no certificate presented" in { + TokenBinding.verify(Mode.ENFORCE, Some(boundThumbprint), None, "test") match { + case Failure(msg, _, _) => msg should include(Oauth2TokenBindingCertificateMissing) + case other => fail(s"Expected Failure, got $other") + } + } + + behavior of "verify in REQUIRED mode" + + it should "reject an unbound token" in { + TokenBinding.verify(Mode.REQUIRED, None, Some(boundCert), "test") match { + case Failure(msg, _, _) => msg should include(Oauth2TokenBindingRequired) + case other => fail(s"Expected Failure, got $other") + } + } + + it should "pass a matching bound token" in { + TokenBinding.verify(Mode.REQUIRED, Some(boundThumbprint), Some(boundCert), "test") shouldBe Full(()) + } + + it should "reject a mismatched bound token" in { + TokenBinding.verify(Mode.REQUIRED, Some(boundThumbprint), Some(otherCert), "test") match { + case Failure(msg, _, _) => msg should include(Oauth2TokenBindingCertificateMismatch) + case other => fail(s"Expected Failure, got $other") + } + } + + it should "reject a bound token with no certificate presented" in { + TokenBinding.verify(Mode.REQUIRED, Some(boundThumbprint), None, "test") match { + case Failure(msg, _, _) => msg should include(Oauth2TokenBindingCertificateMissing) + case other => fail(s"Expected Failure, got $other") + } + } + + behavior of "configuredMode" + + it should "default to NONE when the prop is not set" in { + TokenBinding.configuredMode shouldBe Mode.NONE + } +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 45569068cd..dd3181356a 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -8,7 +8,7 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureAmqpBankBroker, canGetMessageOutbox, canRetryMessageOutbox, canSettleOpenCorridor, canCreateAccount, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, UserFilterParametersNotSupported, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -18,6 +18,7 @@ import code.views.system.ViewPermission import com.openbankproject.commons.model.ViewId import code.routingscheme.RoutingSchemes import code.model.dataAccess.BankAccountRouting +import code.metrics.MappedMetric import code.customer.CustomerX import code.entitlement.Entitlement import code.organisation.Organisations @@ -1296,6 +1297,109 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── getMyMetrics ───────────────────────────────────────────────────────────── + + feature("Http4s700 getMyMetrics endpoint") { + + def createTestMetric(userId: String, userName: String, partialFunctionName: String): Unit = + MappedMetric.create + .userId(userId) + .userName(userName) + .url("/obp/v7.0.0/my/metrics-test") + .date(new Date()) + .duration(42) + .appName("Http4s700RoutesTestApp") + .verb("GET") + .implementedByPartialFunction(partialFunctionName) + .implementedInVersion("v7.0.0") + .correlationId(java.util.UUID.randomUUID().toString) + .save + + scenario("Reject unauthenticated access to /my/metrics", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/my/metrics with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/my/metrics") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Reject a user_id filter with 400 UserFilterParametersNotSupported", Http4s700RoutesTag) { + When("GET /obp/v7.0.0/my/metrics with a user_id filter pointing at resourceUser2") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = + makeHttpRequest(s"/obp/v7.0.0/my/metrics?user_id=${resourceUser2.userId}&limit=500", headers) + + Then("Response is 400 with UserFilterParametersNotSupported naming the offending parameter") + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserFilterParametersNotSupported) + msg should include("user_id") + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Reject username and anon filters with 400", Http4s700RoutesTag) { + When("GET /obp/v7.0.0/my/metrics with username and anon filters") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = + makeHttpRequest("/obp/v7.0.0/my/metrics?username=someone&anon=false", headers) + + Then("Response is 400 naming both offending parameters") + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserFilterParametersNotSupported) + msg should include("username") + msg should include("anon") + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return only the logged in user's own metrics", Http4s700RoutesTag) { + Given("a metric row for resourceUser1 and one for resourceUser2") + createTestMetric(resourceUser1.userId, resourceUser1.name, "getMyMetricsTestOwn") + createTestMetric(resourceUser2.userId, resourceUser2.name, "getMyMetricsTestOther") + + When("GET /obp/v7.0.0/my/metrics with only pagination parameters") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/my/metrics?limit=500", headers) + + Then("Response is 200 and every row belongs to resourceUser1") + statusCode shouldBe 200 + json match { + case JObject(fields) => + toFieldMap(fields).get("metrics") match { + case Some(JArray(rows)) => + rows should not be empty + val userIds = rows.collect { case JObject(f) => toFieldMap(f).get("user_id") }.flatten + userIds.foreach(_ shouldBe JString(resourceUser1.userId)) + val partialFunctions = rows.collect { case JObject(f) => toFieldMap(f).get("implemented_by_partial_function") }.flatten + partialFunctions should contain(JString("getMyMetricsTestOwn")) + partialFunctions should not contain JString("getMyMetricsTestOther") + case other => fail(s"Expected metrics array, got $other") + } + case _ => fail("Expected JSON object for getMyMetrics") + } + } + } + feature("Http4s700 createOrganisation endpoint") { scenario("Reject unauthenticated POST to /organisations", Http4s700RoutesTag) { From fe96326ee7966e0c81fbf0553cc315d768cf863b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 30 Aug 2026 12:58:00 +0200 Subject: [PATCH 3/4] Adding mobile_phone_number, mobile_phone_number_is_validated, mobile_phone_number_validated_date --- .../scala/code/api/v6_0_0/Http4s600.scala | 138 +++++++++++------- .../scala/code/api/v7_0_0/Http4s700.scala | 91 +++++++++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 59 ++++++++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 128 +++++++++++++++- 4 files changed, 357 insertions(+), 59 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 22a584ee1c..42431241dd 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -933,6 +933,83 @@ object Http4s600 { } + // Shared by POST /users in v6.0.0 and v7.0.0 (v7 adds mobile_phone_number). + // Validates the password against the strong-password policy, rejects a + // duplicate username (409), then creates and saves the AuthUser (which also + // creates its ResourceUser). Email validation state follows + // `authUser.skipEmailValidation`. + def createAndSaveAuthUser( + email: String, + username: String, + password: String, + firstName: String, + lastName: String + )(implicit cc: CallContext): Future[AuthUser] = { + for { + _ <- Helper.booleanToFuture(InvalidStrongPasswordFormat, 400, Some(cc)) { + APIUtil.fullPasswordValidation(password) + } + _ <- Helper.booleanToFuture(DuplicateUsername, 409, Some(cc)) { + AuthUser.find(net.liftweb.mapper.By(AuthUser.username, username)).isEmpty + } + userCreated <- Future { + AuthUser.create + .firstName(firstName).lastName(lastName) + .username(username).email(email) + .password(password) + .validated(APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false)) + } + _ <- Helper.booleanToFuture(InvalidJsonFormat + userCreated.validate.map(_.msg).mkString(";"), 400, Some(cc)) { + userCreated.validate.size == 0 + } + savedUser <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { userCreated.saveMe() } + _ <- Helper.booleanToFuture(s"$UnknownError Error occurred during user creation.", 400, Some(cc)) { + userCreated.saved_? + } + } yield savedUser + } + + // Sends the sign-up validation email unless `authUser.skipEmailValidation` + // is on. Delivery problems are logged, never raised: the user row already + // exists and can retry via POST /obp/v7.0.0/users/validation-emails. + def sendSignupValidationEmailIfRequired(savedUser: AuthUser): Unit = { + val skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false) + if (!skipEmailValidation) { + val portalUrlBox = APIUtil.getPortalUrl + val senderAddress = AuthUser.emailFrom + val portalMissing = portalUrlBox.isEmpty + val senderIsDefault = senderAddress == "noreply@example.com" + if (portalMissing) { + logger.warn(s"createUser says: validation email NOT sent for user '${savedUser.username.get}' — public_obp_portal_url (or legacy portal_external_url) is not set. The user will be unable to validate via email. They can use POST /obp/v7.0.0/users/validation-emails to retry once the prop is configured.") + } else if (senderIsDefault) { + logger.warn(s"createUser says: validation email NOT sent for user '${savedUser.username.get}' — mail.users.userinfo.sender.address is still the default 'noreply@example.com' (most SMTP servers will reject this From address).") + } else { + val portalUrl = portalUrlBox.openOr("") + val expiryMinutes = APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(savedUser.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()).build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + val emailLink = portalUrl + "/user-validation?token=" + java.net.URLEncoder.encode(jwtToken, "UTF-8") + val sendOutcome = CommonsEmailWrapper.sendHtmlEmailEither(CommonsEmailWrapper.EmailContent( + from = senderAddress, + to = List(savedUser.email.get), + bcc = AuthUser.bccEmail.toList, + subject = "Sign up confirmation", + textContent = Some(s"Welcome! Please validate your account: $emailLink"), + htmlContent = Some(s"

Welcome! Please validate your account.

") + )) + sendOutcome match { + case Right(msgId) => + logger.info(s"createUser says: validation email sent to '${savedUser.email.get}' messageId=$msgId") + case Left(e) => + logger.warn(s"createUser says: validation email send FAILED for user '${savedUser.username.get}' (${savedUser.email.get}): ${e.getClass.getSimpleName}: ${Option(e.getMessage).getOrElse("").take(200)}. The user can retry via POST /obp/v7.0.0/users/validation-emails once the SMTP issue is resolved.") + } + } + } + } + // Route: POST /obp/v6.0.0/users (201) lazy val createUser: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "users" => @@ -943,64 +1020,13 @@ object Http4s600 { postedData <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CreateUserJsonV600] } - _ <- Helper.booleanToFuture(InvalidStrongPasswordFormat, 400, Some(cc)) { - APIUtil.fullPasswordValidation(postedData.password) - } - _ <- Helper.booleanToFuture(DuplicateUsername, 409, Some(cc)) { - AuthUser.find(net.liftweb.mapper.By(AuthUser.username, postedData.username)).isEmpty - } - userCreated <- Future { - AuthUser.create - .firstName(postedData.first_name).lastName(postedData.last_name) - .username(postedData.username).email(postedData.email) - .password(postedData.password) - .validated(APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false)) - } - _ <- Helper.booleanToFuture(InvalidJsonFormat + userCreated.validate.map(_.msg).mkString(";"), 400, Some(cc)) { - userCreated.validate.size == 0 - } - savedUser <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { userCreated.saveMe() } - _ <- Helper.booleanToFuture(s"$UnknownError Error occurred during user creation.", 400, Some(cc)) { - userCreated.saved_? - } + savedUser <- createAndSaveAuthUser( + postedData.email, postedData.username, postedData.password, postedData.first_name, postedData.last_name + ) } yield { - val skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false) - if (!skipEmailValidation) { - val portalUrlBox = APIUtil.getPortalUrl - val senderAddress = AuthUser.emailFrom - val portalMissing = portalUrlBox.isEmpty - val senderIsDefault = senderAddress == "noreply@example.com" - if (portalMissing) { - logger.warn(s"createUser says: validation email NOT sent for user '${savedUser.username.get}' — public_obp_portal_url (or legacy portal_external_url) is not set. The user will be unable to validate via email. They can use POST /obp/v7.0.0/users/validation-emails to retry once the prop is configured.") - } else if (senderIsDefault) { - logger.warn(s"createUser says: validation email NOT sent for user '${savedUser.username.get}' — mail.users.userinfo.sender.address is still the default 'noreply@example.com' (most SMTP servers will reject this From address).") - } else { - val portalUrl = portalUrlBox.openOr("") - val expiryMinutes = APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440) - val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() - .subject(savedUser.uniqueId.get) - .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) - .issueTime(new java.util.Date()).build() - val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) - val emailLink = portalUrl + "/user-validation?token=" + java.net.URLEncoder.encode(jwtToken, "UTF-8") - val sendOutcome = CommonsEmailWrapper.sendHtmlEmailEither(CommonsEmailWrapper.EmailContent( - from = senderAddress, - to = List(savedUser.email.get), - bcc = AuthUser.bccEmail.toList, - subject = "Sign up confirmation", - textContent = Some(s"Welcome! Please validate your account: $emailLink"), - htmlContent = Some(s"

Welcome! Please validate your account.

") - )) - sendOutcome match { - case Right(msgId) => - logger.info(s"createUser says: validation email sent to '${savedUser.email.get}' messageId=$msgId") - case Left(e) => - logger.warn(s"createUser says: validation email send FAILED for user '${savedUser.username.get}' (${savedUser.email.get}): ${e.getClass.getSimpleName}: ${Option(e.getMessage).getOrElse("").take(200)}. The user can retry via POST /obp/v7.0.0/users/validation-emails once the SMTP issue is resolved.") - } - } - } + sendSignupValidationEmailIfRequired(savedUser) AuthUser.grantDefaultEntitlementsToAuthUser(savedUser) - JSONFactory200.createUserJSONfromAuthUser(userCreated) + JSONFactory200.createUserJSONfromAuthUser(savedUser) } } } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index d44242188b..ed2b29e557 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -974,13 +974,18 @@ object Http4s700 { http4sPartialFunction = Some(getCurrentUser) ) + // Accepted shape of a user's own mobile phone number (POST /users and + // PUT /my/user/mobile-phone-number): optional leading "+", then 5-50 of + // digits, spaces, dashes, dots and parentheses. + private val mobilePhoneNumberRegex = """\+?[0-9\-\s().]{5,50}""" + // Route: PUT /obp/v7.0.0/my/user/mobile-phone-number val updateMyMobilePhoneNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "my" / "user" / "mobile-phone-number" => EndpointHelpers.withUserAndBody[JSONFactory700.PutMyMobilePhoneNumberJsonV700, JSONFactory700.MyMobilePhoneNumberJsonV700](req) { (user, body, cc) => for { _ <- Helper.booleanToFuture(InvalidPhoneNumber, cc = Some(cc)) { - body.mobile_phone_number.matches("""\+?[0-9\-\s().]{5,50}""") + body.mobile_phone_number.matches(mobilePhoneNumberRegex) } resourceUser <- Future { UserVend.users.vend.getResourceUserByResourceUserId(user.userPrimaryKey.value) @@ -1028,6 +1033,90 @@ object Http4s700 { http4sPartialFunction = Some(updateMyMobilePhoneNumber) ) + // Route: POST /obp/v7.0.0/users (201) + // v7 signature change over v6: the body accepts an optional mobile_phone_number, + // stored on the ResourceUser as unverified (is_validated=false, no validated + // date) — verification is a separate flow. Password policy, duplicate-username + // check, validation email and default entitlements are shared with v6. + val createUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JSONFactory700.CreateUserJsonV700]}", + 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[JSONFactory700.CreateUserJsonV700] + } + mobilePhoneNumber = postedData.mobile_phone_number.map(_.trim).filter(_.nonEmpty) + _ <- Helper.booleanToFuture(InvalidPhoneNumber, 400, Some(cc)) { + mobilePhoneNumber.forall(_.matches(mobilePhoneNumberRegex)) + } + savedUser <- code.api.v6_0_0.Http4s600.Implementations6_0_0.createAndSaveAuthUser( + postedData.email, postedData.username, postedData.password, postedData.first_name, postedData.last_name + ) + resourceUser <- Future { + UserVend.users.vend.getResourceUserByResourceUserId(savedUser.user.get) + } map { x => unboxFullOrFail(x, Some(cc), UserNotFoundByUserId, 404) } + storedResourceUser <- Future { + mobilePhoneNumber match { + case Some(number) => + resourceUser.MobilePhoneNumber(number).MobilePhoneNumberIsValidated(false).saveMe() + case None => resourceUser + } + } + } yield { + code.api.v6_0_0.Http4s600.Implementations6_0_0.sendSignupValidationEmailIfRequired(savedUser) + AuthUser.grantDefaultEntitlementsToAuthUser(savedUser) + JSONFactory700.createCreatedUserJsonV700( + JSONFactory200.createUserJSONfromAuthUser(savedUser), + storedResourceUser + ) + } + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createUser), + "POST", + "/users", + "Create User (self-registration)", + s"""Creates an OBP user (self-registration). No authorisation required. + | + |Requires email, username, password, first_name and last_name. + | + |v7.0.0 adds the optional `mobile_phone_number`: the registering person's own + |number (global across banks, distinct from the bank-scoped `mobile_phone_number` + |on Customer, which is KYC data of a legal entity). It is stored unverified — + |`mobile_phone_number_is_validated` is `false` and + |`mobile_phone_number_validated_date` is empty until a separate validation flow + |succeeds. Omit the field, or send null / blank, to register without a number. + | + |Validation checks performed: + |- Password must meet strong password requirements ($InvalidStrongPasswordFormat) + |- Username must be unique (409, $DuplicateUsername) + |- `mobile_phone_number`, when present, must be an optional leading `+` followed by + | 5 to 50 digits, spaces, dashes, dots or parentheses ($InvalidPhoneNumber) + | + |Email validation behavior: + |- Controlled by property `authUser.skipEmailValidation` (default: false) + |- When false: the user is created with validated=false and a validation email is sent. + | The link uses `public_obp_portal_url` (or legacy `portal_external_url`); if that is + | not set, or sending fails, the user can retry via POST /obp/v7.0.0/users/validation-emails. + |- When true: the user is created with validated=true and no email is sent. + |- Default entitlements are granted immediately regardless of validation status. + | + |""".stripMargin, + JSONFactory700.createUserJsonV700Example, + JSONFactory700.createdUserJsonV700Example, + List(InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, InvalidPhoneNumber, "Error occurred during user creation.", UnknownError), + List(apiTagUser, apiTagOnboarding), + None, + http4sPartialFunction = Some(createUser) + ) + // Route: GET /obp/v7.0.0/my/metrics // Same fetch path as GET /management/metrics (APIMetrics.getMetricsFromHttpParams) // with the user filter locked to the logged-in user. diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 7c4536ccc1..671897dafc 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -1622,6 +1622,65 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { mobile_phone_number_validated_date = None ) + // ─── Create User (self-registration) — v7 adds the optional mobile phone number ── + // The number belongs to the person registering (global across banks, stored on + // ResourceUser) and is stored UNVERIFIED: is_validated=false, no validated date. + // Verification is a separate flow. Absent or blank means "no number". + case class CreateUserJsonV700( + email: String, + username: String, + password: String, + first_name: String, + last_name: String, + mobile_phone_number: Option[String] + ) + + case class CreatedUserJsonV700( + user_id: String, + email: String, + provider_id: String, + provider: String, + username: String, + mobile_phone_number: Option[String], + mobile_phone_number_is_validated: Option[Boolean], + mobile_phone_number_validated_date: Option[Date], + entitlements: EntitlementJSONs + ) + + def createCreatedUserJsonV700(v200: code.api.v2_0_0.JSONFactory200.UserJsonV200, resourceUser: User): CreatedUserJsonV700 = + CreatedUserJsonV700( + user_id = v200.user_id, + email = v200.email, + provider_id = v200.provider_id, + provider = v200.provider, + username = v200.username, + mobile_phone_number = resourceUser.mobilePhoneNumber, + mobile_phone_number_is_validated = resourceUser.mobilePhoneNumberIsValidated, + mobile_phone_number_validated_date = resourceUser.mobilePhoneNumberValidatedDate, + entitlements = v200.entitlements + ) + + lazy val createUserJsonV700Example = CreateUserJsonV700( + email = ExampleValue.emailExample.value, + username = ExampleValue.usernameExample.value, + password = "String", + first_name = "Simon", + last_name = "Redfern", + mobile_phone_number = Some(ExampleValue.mobileNumberExample.value) + ) + + lazy val createdUserJsonV700Example = CreatedUserJsonV700( + user_id = ExampleValue.userIdExample.value, + email = ExampleValue.emailExample.value, + provider_id = ExampleValue.providerIdValueExample.value, + provider = ExampleValue.providerValueExample.value, + username = ExampleValue.usernameExample.value, + mobile_phone_number = Some(ExampleValue.mobileNumberExample.value), + mobile_phone_number_is_validated = Some(false), + mobile_phone_number_validated_date = None, + entitlements = EntitlementJSONs(Nil) + ) + // ─── Password policy — published so clients can validate locally before user creation / // password reset. The structured fields are the normative contract; `regex` is a convenience // written in the portable subset that behaves identically in Java, JavaScript and Python. diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index dd3181356a..720deb43c7 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -8,7 +8,7 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureAmqpBankBroker, canGetMessageOutbox, canRetryMessageOutbox, canSettleOpenCorridor, canCreateAccount, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, UserFilterParametersNotSupported, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, DuplicateUsername, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, UserFilterParametersNotSupported, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -17,7 +17,8 @@ import code.views.MapperViews import code.views.system.ViewPermission import com.openbankproject.commons.model.ViewId import code.routingscheme.RoutingSchemes -import code.model.dataAccess.BankAccountRouting +import code.model.dataAccess.{AuthUser, BankAccountRouting} +import net.liftweb.util.Helpers.randomString import code.metrics.MappedMetric import code.customer.CustomerX import code.entitlement.Entitlement @@ -1193,6 +1194,129 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── createUser (v7 native — adds the optional mobile_phone_number) ─────────── + + feature("Http4s700 createUser endpoint") { + + val strongPassword = "StrongP@ssw0rd123!" + + def createUserBody(username: String, mobilePhoneNumberJson: Option[String]): String = { + val phone = mobilePhoneNumberJson.map(v => s""","mobile_phone_number":$v""").getOrElse("") + s"""{"email":"$username","username":"$username","password":"$strongPassword","first_name":"Simon","last_name":"Redfern"$phone}""" + } + + def newUsername(): String = "v7reg" + randomString(10).toLowerCase + "@example.com" + + def deleteAuthUser(username: String): Unit = + AuthUser.find(By(AuthUser.username, username)).foreach(_.delete_!) + + scenario("Create a user with a mobile phone number, stored unverified, served natively by v7", Http4s700RoutesTag) { + Given("email validation is skipped and a fresh username") + setPropsValues("authUser.skipEmailValidation" -> "true") + val username = newUsername() + + When("POST /obp/v7.0.0/users with mobile_phone_number") + val (statusCode, json, headers) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users", createUserBody(username, Some("\"+49 170 5556677\""))) + + Then("Response is 201 from v7 itself (no version-served fallback header), with the phone fields") + statusCode shouldBe 201 + hasHeader(headers, "X-OBP-Version-Served") shouldBe false + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.get("username") shouldBe Some(JString(username)) + m.get("email") shouldBe Some(JString(username)) + m.get("user_id") match { + case Some(JString(id)) => id should not be empty + case other => fail(s"Expected user_id, got $other") + } + m.get("mobile_phone_number") shouldBe Some(JString("+49 170 5556677")) + m.get("mobile_phone_number_is_validated") shouldBe Some(JBool(false)) + m.get("mobile_phone_number_validated_date") should (be(None) or be(Some(JNull))) + m.keys should contain("entitlements") + case _ => fail("Expected JSON object for createUser") + } + + And("the ResourceUser carries the number, unverified") + val authUser = AuthUser.find(By(AuthUser.username, username)).openOrThrowException("user must have been created") + val ru = code.model.dataAccess.ResourceUser.find(By(code.model.dataAccess.ResourceUser.id, authUser.user.get)) + .openOrThrowException("resource user must exist") + ru.mobilePhoneNumber shouldBe Some("+49 170 5556677") + ru.mobilePhoneNumberIsValidated shouldBe Some(false) + ru.mobilePhoneNumberValidatedDate shouldBe None + + deleteAuthUser(username) + } + + scenario("Create a user without a mobile phone number", Http4s700RoutesTag) { + Given("email validation is skipped and a fresh username") + setPropsValues("authUser.skipEmailValidation" -> "true") + val username = newUsername() + + When("POST /obp/v7.0.0/users with the v6-shaped body (no mobile_phone_number)") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/users", createUserBody(username, None)) + + Then("Response is 201 and the phone fields are empty") + statusCode shouldBe 201 + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.get("username") shouldBe Some(JString(username)) + m.get("mobile_phone_number") should (be(None) or be(Some(JNull))) + case _ => fail("Expected JSON object for createUser") + } + + deleteAuthUser(username) + } + + scenario("Reject a malformed mobile phone number without creating the user", Http4s700RoutesTag) { + Given("a fresh username") + setPropsValues("authUser.skipEmailValidation" -> "true") + val username = newUsername() + + When("POST /obp/v7.0.0/users with letters in mobile_phone_number") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/users", createUserBody(username, Some("\"call me maybe\""))) + + Then("Response is 400 with InvalidPhoneNumber and no user row exists") + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(InvalidPhoneNumber) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + AuthUser.find(By(AuthUser.username, username)).isDefined shouldBe false + } + + scenario("Reject a duplicate username with 409", Http4s700RoutesTag) { + Given("a user that already exists") + setPropsValues("authUser.skipEmailValidation" -> "true") + val username = newUsername() + val (first, _, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/users", createUserBody(username, None)) + first shouldBe 201 + + When("POST /obp/v7.0.0/users again with the same username") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/users", createUserBody(username, None)) + + Then("Response is 409 with DuplicateUsername") + statusCode shouldBe 409 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(DuplicateUsername) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + + deleteAuthUser(username) + } + } + // ─── updateMyMobilePhoneNumber ──────────────────────────────────────────────── feature("Http4s700 updateMyMobilePhoneNumber endpoint") { From 29deedbb5c9eb493ebfe169c2edb01b2c634a6ae Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 30 Aug 2026 22:17:16 +0200 Subject: [PATCH 4/4] my metrics and test for like for like field values across CallContext and CallContextLight so they can never diverge. --- .../SwaggerDefinitionsJSON.scala | 10 ++ .../main/scala/code/api/util/ApiSession.scala | 34 ++++-- .../main/scala/code/api/util/OBPParam.scala | 3 + .../scala/code/api/v6_0_0/Http4s600.scala | 18 ++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 31 ++++++ .../scala/code/api/v7_0_0/Http4s700.scala | 14 ++- .../main/scala/code/metrics/APIMetrics.scala | 29 +++-- .../code/metrics/DoobieMetricsQueries.scala | 22 +++- .../scala/code/metrics/MappedMetrics.scala | 33 +++++- ...UKOpenBankingV401ConsentScopingTests.scala | 3 +- .../api/v6_0_0/AggregateMetricsTest.scala | 100 +++++++++++++++++ .../test/scala/code/util/ApiSessionTest.scala | 104 ++++++++++++++++++ 12 files changed, 370 insertions(+), 31 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.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 72e58fcc88..2ca0eeef91 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 @@ -3208,6 +3208,16 @@ object SwaggerDefinitionsJSON { lazy val metricsJsonV600 = MetricsJsonV600( metrics = List(metricJsonV600) ) + lazy val aggregateMetricJsonV600 = AggregateMetricJsonV600( + count = 7076, + average_response_time = 65.21, + minimum_response_time = 1, + maximum_response_time = 9039, + distinct_user_count = 41, + distinct_consumer_count = 12, + consent_call_count = 1024, + distinct_consent_count = 9 + ) lazy val branchJsonPut = BranchJsonPutV210("gh.29.fi", "OBP", addressJsonV140, diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 3cecbf1058..cdd812050d 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -29,8 +29,25 @@ case class CallContext( dauthRequestPayload: Option[JSONFactoryDAuth.PayloadOfJwtJSON] = None, //Never update these values inside the case class !!! dauthResponseHeader: Option[String] = None, spelling: Option[String] = None, + // The AUTHENTICATED principal. Not always a person: under a consent this is the + // consent's own shadow user. Stored data (metric rows, created_by_user_id columns) + // always records this id — the human is resolved at read time via the consent table. user: Box[User] = Empty, + // The human who CREATED the consent this request runs under. Populated only by the + // OBP-native consent path (applyConsentRulesCommon) from the consent JWT's + // createdByUserId claim, resolved against the users table. For OBP-native consents + // the creator is the granting human (they create their own consent in the Portal). + // Not set by Berlin Group / UK flows, where the consent may be created by a TPP flow + // with no human logged in — see `consenter` for those. + // Read via humanUser / effectiveHumanUserId, where it takes precedence over consenter. onBehalfOfUser: Box[User] = Empty, + // The human (PSU) who AUTHORISED the consent this request runs under — the owner of + // record, from the consent table's userId (bound by updateConsentUser during the + // authorise ceremony). Populated by the Berlin Group and UK consent paths, whose + // consents are created by TPP flows and only gain their human at authorisation. + // The UK ownership check (checkUKConsent) compares the consent's userId against this. + // In practice onBehalfOfUser and consenter are never both set: each consent standard + // populates the one whose source is authoritative for it. consenter: Box[User] = Empty, consumer: Box[Consumer] = Empty, ipAddress: String = "", @@ -94,7 +111,9 @@ case class CallContext( * `user` is not always a person: a consent resolves to a shadow user that exists only for that * consent (Berlin Group, OBP-native, and -- since UK consents moved to the same model -- UK too). * Anything that must name a human rather than a principal reads this instead: the CBS adapter, - * which tells the core banking system who is asking, and metric attribution. + * which tells the core banking system who is asking, and the consent ownership checks. + * Stored data (metric rows included) always carries the authenticated principal; the human is + * resolved at read time via the consent table (see effectiveHumanUserId). */ def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user) @@ -159,12 +178,13 @@ case class CallContext( CallContextLight( gatewayLoginRequestPayload = this.gatewayLoginRequestPayload, gatewayLoginResponseHeader = this.gatewayLoginResponseHeader, - // Metrics name the human, not the principal. A consent's shadow user would record a per-consent - // UUID and an empty username, which is what Berlin Group and OBP-native traffic has always - // looked like on the metrics table; the consent itself stays identifiable via - // consentReferenceId below. - userId = this.humanUser.map(_.userId).toOption, - userName = this.humanUser.map(_.name).toOption, + // Like for like with CallContext: userId/userName are the AUTHENTICATED principal + // (CallContext.user), never a resolved human. Under a consent that principal is the + // consent's own shadow user (a per-consent UUID with an empty name) — the on-behalf-of + // human is not stored here but resolved at read time via the consent table + // (consentReferenceId below -> consent.userId), see CallContext.effectiveHumanUserId. + userId = this.user.map(_.userId).toOption, + userName = this.user.map(_.name).toOption, consumerId = this.consumer.map(_.consumerId.get).toOption, appName = this.consumer.map(_.name.get).toOption, developerEmail = this.consumer.map(_.developerEmail.get).toOption, diff --git a/obp-api/src/main/scala/code/api/util/OBPParam.scala b/obp-api/src/main/scala/code/api/util/OBPParam.scala index 4067525668..1c6b73698e 100644 --- a/obp-api/src/main/scala/code/api/util/OBPParam.scala +++ b/obp-api/src/main/scala/code/api/util/OBPParam.scala @@ -70,6 +70,9 @@ case class OBPConsentReferenceId(value: String) extends OBPQueryParam // PeerTrust.Resolution.mode on the metric row: "direct", "forwarded" or "none". case class OBPCertificateTrust(value: String) extends OBPQueryParam case class OBPUserId(value: String) extends OBPQueryParam +// Multiple user ids, matched with SQL IN — used by self-service endpoints that lock the +// user filter to a server-resolved set (e.g. /my/metrics: the human plus their consent-agents). +case class OBPUserIds(values: List[String]) extends OBPQueryParam case class ProviderProviderId(value: String) extends OBPQueryParam case class OBPStatus(value: String) extends OBPQueryParam case class OBPBankId(value: String) extends OBPQueryParam diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 42431241dd..ff0f80087e 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -666,10 +666,13 @@ object Http4s600 { } (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture( APIMetrics.applyMetricsFromDateDefault(httpParams), cc.callContext) - aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, false) map { + // isNewVersion = true: v6 is include_* style (exclude_* is rejected above). With + // false the include_app_names / include_url_patterns / + // include_implemented_by_partial_functions filters were silently ignored. + aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, true) map { APIUtil.unboxFullOrFail(_, callContext, GetAggregateMetricsError) } - } yield JSONFactory300.createAggregateMetricJson(aggregateMetrics) + } yield JSONFactory600.createAggregateMetricJsonV600(aggregateMetrics) } } @@ -7516,9 +7519,18 @@ object Http4s600 { | |15 http_status_code (if null ignore) - Filter by HTTP status code. eg: http_status_code=200 returns only successful calls, http_status_code=500 returns server errors | + |**Response fields added in v6.0.0:** + | + |- `distinct_user_count` - distinct humans behind the calls. Calls made under a Consent + |(e.g. by an agent or TPP) are attributed to the granting (on-behalf-of) user resolved via + |the consent table, not to the consent's technical shadow user. Anonymous calls are excluded. + |- `distinct_consumer_count` - distinct Consumers (apps) that made calls. + |- `consent_call_count` - calls that arrived under a Consent. + |- `distinct_consent_count` - distinct Consents exercised in the window. + | """.stripMargin, EmptyBody, - aggregateMetricsJSONV300, + aggregateMetricJsonV600, List( AuthenticatedUserIsRequired, UserHasMissingRoles, 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 878c77d546..52d6152f36 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 @@ -479,6 +479,20 @@ case class MetricJsonV600( ) case class MetricsJsonV600(metrics: List[MetricJsonV600]) +case class AggregateMetricJsonV600( + count: Int, + average_response_time: Double, + minimum_response_time: Double, + maximum_response_time: Double, + // Distinct humans: consent-borne calls are attributed to the granting (on-behalf-of) + // user via the consent table, not to the consent's technical shadow user. + distinct_user_count: Int, + distinct_consumer_count: Int, + // Calls made under a consent, and the number of distinct consents exercised. + consent_call_count: Int, + distinct_consent_count: Int +) + case class CacheNamespaceJsonV600( prefix: String, description: String, @@ -1746,6 +1760,23 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { createMetricsJsonV600(metrics, lookupMap) } + // Same list shape as JSONFactory300.createAggregateMetricJson (a single-element array), + // extended with the distinct/consent counts introduced in v6.0.0. + def createAggregateMetricJsonV600(aggregateMetrics: List[code.metrics.AggregateMetrics]): List[AggregateMetricJsonV600] = { + aggregateMetrics.map(aggregateMetric => + AggregateMetricJsonV600( + aggregateMetric.totalCount, + aggregateMetric.avgResponseTime, + aggregateMetric.minResponseTime, + aggregateMetric.maxResponseTime, + aggregateMetric.distinctUserCount, + aggregateMetric.distinctConsumerCount, + aggregateMetric.consentCallCount, + aggregateMetric.distinctConsentCount + ) + ) + } + def createBankJSON600( bank: Bank, attributes: List[BankAttributeTrait] = Nil diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index ed2b29e557..e11a5bb425 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -1134,8 +1134,13 @@ object Http4s700 { cc = Some(cc)) { identityParams.isEmpty } + // "My" spans the delegation family: the human plus every agent user minted + // from a Consent they granted (metric rows record the authenticated principal, + // so an agent's calls sit under the agent's own user id). Resolve up to the + // human, then fan down — both via server-written columns only. (metrics, _) <- APIMetrics.getMetricsFromHttpParams( - httpParams, cc.callContext, lockedUserId = Some(user.userId)) + httpParams, cc.callContext, + lockedUserIds = Some(humanAndAgentUserIds(cc.effectiveHumanUserId))) } yield JSONFactory600.createMetricsJsonV600(metrics) } } @@ -1148,10 +1153,13 @@ object Http4s700 { "Get Metrics (My)", s"""Get the API metrics rows of the currently authenticated user — a record of each REST API call this user has made. | - |No role is required: this endpoint only ever returns the logged in user's own calls. + |No role is required: this endpoint only ever returns calls belonging to the logged in user — + |their own calls, plus calls made by agent users minted from Consents this user granted + |(e.g. an AI agent calling on their behalf). Called under such a Consent, it returns the + |same family of calls, resolved through the granting user. |The identity filter parameters accepted by `GET /management/metrics` (`user_id`, `username`, `email`, |`provider_provider_id`, `anon`) are NOT supported here and are rejected with an error — - |the user filter is always the current user. + |the user filter is always the current user's delegation family. | |**NOTE: Automatic from_date Default** | diff --git a/obp-api/src/main/scala/code/metrics/APIMetrics.scala b/obp-api/src/main/scala/code/metrics/APIMetrics.scala index 4a68def7bc..70c321ffb2 100644 --- a/obp-api/src/main/scala/code/metrics/APIMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/APIMetrics.scala @@ -52,22 +52,29 @@ object APIMetrics extends SimpleInjector { // One shared fetch path for metrics-reading endpoints: builds OBPQueryParams // from the http params (with the from_date default applied) and runs the query. - // lockedUserId pins the user filter server-side (for self-service endpoints + // lockedUserIds pins the user filter server-side (for self-service endpoints // like GET /my/metrics); when set it overrides anything in httpParams. def getMetricsFromHttpParams( httpParams: List[HTTPParam], callContext: Option[CallContext], - lockedUserId: Option[String] = None + lockedUserIds: Option[List[String]] = None ): Future[(List[APIMetric], Option[CallContext])] = { - val effectiveParams = lockedUserId match { - case Some(userId) => - httpParams.filterNot(_.name == "user_id") :+ HTTPParam("user_id", List(userId)) + // The lock replaces any caller-supplied user filter outright: the ids are + // server-resolved (e.g. the human plus their consent-agents for /my/metrics) + // and nothing from the request may widen or narrow them. + val effectiveParams = lockedUserIds match { + case Some(_) => httpParams.filterNot(_.name == "user_id") case None => httpParams } for { (obpQueryParams, cc) <- createQueriesByHttpParamsFuture( applyMetricsFromDateDefault(effectiveParams), callContext) - metrics <- Future(apiMetrics.vend.getAllMetrics(obpQueryParams)) + lockedParams = lockedUserIds match { + case Some(userIds) => + code.api.util.OBPUserIds(userIds) :: obpQueryParams.filterNot(_.isInstanceOf[code.api.util.OBPUserId]) + case None => obpQueryParams + } + metrics <- Future(apiMetrics.vend.getAllMetrics(lockedParams)) } yield (metrics, cc) } @@ -194,7 +201,15 @@ case class AggregateMetrics( totalCount: Int, avgResponseTime: Double, minResponseTime: Double, - maxResponseTime: Double + maxResponseTime: Double, + // Distinct humans behind the calls: consent-borne rows are attributed to the granting + // (on-behalf-of) user via the consent table, mirroring CallContext.effectiveHumanUserId. + distinctUserCount: Int, + distinctConsumerCount: Int, + // Calls that arrived under a consent (metric.consent_reference_id not null), and how many + // distinct consents were exercised in the window. + consentCallCount: Int, + distinctConsentCount: Int ) case class TopApi( diff --git a/obp-api/src/main/scala/code/metrics/DoobieMetricsQueries.scala b/obp-api/src/main/scala/code/metrics/DoobieMetricsQueries.scala index 4cade8d6c3..5af4d72b3b 100644 --- a/obp-api/src/main/scala/code/metrics/DoobieMetricsQueries.scala +++ b/obp-api/src/main/scala/code/metrics/DoobieMetricsQueries.scala @@ -67,9 +67,17 @@ object DoobieMetricsQueries { val toTs = new java.sql.Timestamp(toDate.getTime) // Build dynamic WHERE conditions + // Consent-borne calls are attributed to the granting (on-behalf-of) human via the consent + // table (COALESCE below) — see MappedMetrics.getAllAggregateMetricsBox for the rationale. + // The consent side of the join is unique-indexed on consent_reference_id (no row fan-out). val baseQuery = fr""" - SELECT count(*), avg(duration), min(duration), max(duration) - FROM metric + SELECT count(*), avg(duration), min(duration), max(duration), + count(DISTINCT CASE WHEN COALESCE(c.muserid, m.userid) <> 'null' THEN COALESCE(c.muserid, m.userid) END), + count(DISTINCT CASE WHEN m.consumerid <> '' AND m.consumerid <> 'null' THEN m.consumerid END), + count(NULLIF(m.consent_reference_id, '')), + count(DISTINCT NULLIF(m.consent_reference_id, '')) + FROM metric m + LEFT JOIN mappedconsent c ON m.consent_reference_id = c.consent_reference_id WHERE date_c >= $fromTs AND date_c <= $toTs """ @@ -77,13 +85,17 @@ object DoobieMetricsQueries { val conditions = buildFilterConditions(filters, isNewVersion) val fullQuery = baseQuery ++ conditions - fullQuery.query[(Long, Option[Double], Option[Double], Option[Double])].to[List].map { rows => - rows.map { case (count, avgOpt, minOpt, maxOpt) => + fullQuery.query[(Long, Option[Double], Option[Double], Option[Double], Long, Long, Long, Long)].to[List].map { rows => + rows.map { case (count, avgOpt, minOpt, maxOpt, distinctUsers, distinctConsumers, consentCalls, distinctConsents) => AggregateMetrics( count.toInt, avgOpt.map(d => BigDecimal(d).setScale(2, BigDecimal.RoundingMode.HALF_UP).toDouble).getOrElse(0.0), minOpt.getOrElse(0.0), - maxOpt.getOrElse(0.0) + maxOpt.getOrElse(0.0), + distinctUsers.toInt, + distinctConsumers.toInt, + consentCalls.toInt, + distinctConsents.toInt ) } } diff --git a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala index 417659ad68..39ad61906e 100644 --- a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala @@ -279,6 +279,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ val consumerId = queryParams.collect { case OBPConsumerId(value) => By(MappedMetric.consumerId, value)}.headOption val bankId = queryParams.collect { case OBPBankId(value) => Like(MappedMetric.url, s"%banks/$value%") }.headOption val userId = queryParams.collect { case OBPUserId(value) => By(MappedMetric.userId, value) }.headOption + val userIds = queryParams.collect { case OBPUserIds(values) => net.liftweb.mapper.ByList(MappedMetric.userId, values) }.headOption val url = queryParams.collect { case OBPUrl(value) => By(MappedMetric.url, value) }.headOption val appName = queryParams.collect { case OBPAppName(value) => By(MappedMetric.appName, value) }.headOption val implementedInVersion = queryParams.collect { case OBPImplementedInVersion(value) => By(MappedMetric.implementedInVersion, value) }.headOption @@ -305,6 +306,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ ordering, consumerId.toSeq, userId.toSeq, + userIds.toSeq, bankId.toSeq, url.toSeq, appName.toSeq, @@ -426,10 +428,22 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ val includeUrlPatternsQueries = extendLikeQuery(includeUrlPatternsList, true) val includeUrlPatternsQueriesSql = s"$includeUrlPatternsQueries" + // The LEFT JOIN attributes consent-borne calls to the granting (on-behalf-of) human: + // metric.userid records the AUTHENTICATED principal, which under a consent is the + // consent's own shadow user. COALESCE(consent.muserid, metric.userid) resolves such rows + // to the granting human at read time, mirroring CallContext.effectiveHumanUserId. (Rows + // written 2026-08 only, while toLight briefly recorded the human, resolve identically.) + // The consent side of the join is unique-indexed on consent_reference_id, so the join + // cannot fan out rows. val result = { val sqlQuery = if(isNewVersion) // in the version, we use includeXxx instead of excludeXxx, the performance should be better. - s"""SELECT count(*), avg(duration), min(duration), max(duration) - FROM metric + s"""SELECT count(*), avg(duration), min(duration), max(duration), + count(DISTINCT CASE WHEN COALESCE(c.muserid, m.userid) <> 'null' THEN COALESCE(c.muserid, m.userid) END), + count(DISTINCT CASE WHEN m.consumerid <> '' AND m.consumerid <> 'null' THEN m.consumerid END), + count(NULLIF(m.consent_reference_id, '')), + count(DISTINCT NULLIF(m.consent_reference_id, '')) + FROM metric m + LEFT JOIN mappedconsent c ON m.consent_reference_id = c.consent_reference_id WHERE date_c >= '${sqlTimestamp(fromDate.get)}' AND date_c <= '${sqlTimestamp(toDate.get)}' AND (${trueOrFalse(consumerId.isEmpty)} or consumerid = ${sqlFriendly(consumerId)}) @@ -448,8 +462,13 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ AND (${trueOrFalse(includeImplementedByPartialFunctions.isEmpty) } or implementedbypartialfunction in ($includeImplementedByPartialFunctionsList)) """.stripMargin else - s"""SELECT count(*), avg(duration), min(duration), max(duration) - FROM metric + s"""SELECT count(*), avg(duration), min(duration), max(duration), + count(DISTINCT CASE WHEN COALESCE(c.muserid, m.userid) <> 'null' THEN COALESCE(c.muserid, m.userid) END), + count(DISTINCT CASE WHEN m.consumerid <> '' AND m.consumerid <> 'null' THEN m.consumerid END), + count(NULLIF(m.consent_reference_id, '')), + count(DISTINCT NULLIF(m.consent_reference_id, '')) + FROM metric m + LEFT JOIN mappedconsent c ON m.consent_reference_id = c.consent_reference_id WHERE date_c >= '${sqlTimestamp(fromDate.get)}' AND date_c <= '${sqlTimestamp(toDate.get)}' AND (${trueOrFalse(consumerId.isEmpty)} or consumerid = ${sqlFriendly(consumerId)}) @@ -477,7 +496,11 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ tryo(rs(0).toInt).getOrElse(0), tryo("%.2f".format(rs(1).toDouble).toDouble).getOrElse(0), tryo(rs(2).toDouble).getOrElse(0), - tryo(rs(3).toDouble).getOrElse(0) + tryo(rs(3).toDouble).getOrElse(0), + tryo(rs(4).toInt).getOrElse(0), + tryo(rs(5).toInt).getOrElse(0), + tryo(rs(6).toInt).getOrElse(0), + tryo(rs(7).toInt).getOrElse(0) ) ) logger.debug("code.metrics.MappedMetrics.getAllAggregateMetricsBox.sqlResult --: " + sqlResult) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala index f7a9e4152d..88d3de7b02 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -249,7 +249,8 @@ class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup resolved.userId should not equal resourceUser1.userId // The PSU has to survive the swap: checkUKConsent compares the consent's owner against it, - // and the CBS adapter and metrics both name it. + // and the CBS adapter names it. (Metric rows record the principal; the PSU behind a + // consent-borne row is resolved via the consent table at read time.) cc.consenter.map(_.userId) should equal(Full(resourceUser1.userId)) UserExtended(resolved).hasAccountAccess(systemView(ReadAccountsBasic), bankIdAccountId, Some(cc)) should equal(true) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala new file mode 100644 index 0000000000..ae8155323e --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/AggregateMetricsTest.scala @@ -0,0 +1,100 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanReadAggregateMetrics +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} +import code.entitlement.Entitlement +import code.metrics.MetricBatchWriter +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * Tests GET /obp/v6.0.0/management/aggregate-metrics, in particular the fields added in v6.0.0: + * distinct_user_count, distinct_consumer_count, consent_call_count, distinct_consent_count. + * + * distinct_user_count is on-behalf-of aware: consent-borne rows are attributed to the granting + * human via the consent table (see MappedMetrics.getAllAggregateMetricsBox). The consent path is + * not exercised here — spinning up a consent in this harness is disproportionate — so this suite + * pins the plain-auth behaviour (consent_call_count == 0) and the consent attribution is verified + * manually against a running instance (create a consent, call with it, and check the counts). + */ +class AggregateMetricsTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag("getAggregateMetrics") + + feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") { + scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0") + val request = (v6_0_0_Request / "management" / "aggregate-metrics").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) + } + } + + feature(s"test $ApiEndpoint1 version $VersionOfApi - Missing role") { + scenario("We will call the endpoint with user credentials but without a proper entitlement", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0") + val request = (v6_0_0_Request / "management" / "aggregate-metrics").GET <@ (user1) + val response = makeGetRequest(request) + Then("error should be " + UserHasMissingRoles + CanReadAggregateMetrics) + response.code should equal(403) + response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanReadAggregateMetrics) + } + } + + feature(s"test $ApiEndpoint1 version $VersionOfApi - Aggregate counts including v6.0.0 distinct fields") { + scenario("We make traffic as two users and check count and the distinct/consent fields", ApiEndpoint1, VersionOfApi) { + setPropsValues("write_metrics" -> "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanReadAggregateMetrics.toString) + + // Counts are asserted behind a url filter unique to this traffic (GET /obp/v5.1.0/banks): + // write_metrics may already be true when this class runs (props are JVM-wide and an earlier + // suite may have set it), so calls made by the scenarios above — and the aggregate-metrics + // requests themselves — are also on the metric table. The url filter keeps them out. + val trafficUrl = "/obp/v5.1.0/banks" + + // 5 calls as user1 (consumer: testConsumer), 3 as user2 (consumer: testConsumer2) — + // asymmetric on purpose, so a swapped or ignored filter cannot produce the right numbers. + val requestBanks1 = (v5_1_0_Request / "banks").GET <@ (user1) + (1 to 5).foreach(_ => makeGetRequest(requestBanks1)) + val requestBanks2 = (v5_1_0_Request / "banks").GET <@ (user2) + (1 to 3).foreach(_ => makeGetRequest(requestBanks2)) + + MetricBatchWriter.flush() + + When("We query aggregate-metrics filtered to user1's consumer and the traffic url") + val request = (v6_0_0_Request / "management" / "aggregate-metrics").GET <@ (user1) < "dl-token"), + httpCode = Some(201), + httpBody = Some("""{"ok":true}"""), + requestHeaders = List(HTTPParam("X-Request-ID", List("rid-1"))), + xRateLimitLimit = 100L, + xRateLimitRemaining = 99L, + xRateLimitReset = 42L, + paginationOffset = Some("10"), + paginationLimit = Some("50"), + consentReferenceId = Some("consent-ref-1"), + certificateTrust = Some("forwarded"), + certificateTrustDetail = Some("cn=proxy") + ) + val light = cc.toLight + + // Box (on CallContext) vs Option (on CallContextLight) is a representation + // difference, not a value difference — compare through Option. + def normalise(value: Any): Any = value match { + case box: Box[_] => box.toOption + case other => other + } + + val ccFields = cc.productElementNames.zip(cc.productIterator).toMap + val lightFields = light.productElementNames.zip(light.productIterator).toMap + val sharedNames = ccFields.keySet.intersect(lightFields.keySet) + + // Guard the guard: if a rename ever shrinks the overlap, fail loudly instead of + // silently comparing less. + sharedNames should contain allOf( + "correlationId", "url", "verb", "implementedInVersion", "startTime", "endTime", + "operationId", "httpCode", "httpBody", "authReqHeaderField", "requestHeaders", + "consentReferenceId", "certificateTrust", "certificateTrustDetail", + "paginationOffset", "paginationLimit", + "xRateLimitLimit", "xRateLimitRemaining", "xRateLimitReset") + + for (name <- sharedNames) { + withClue(s"CallContext.$name vs CallContextLight.$name: ") { + normalise(lightFields(name)) should be(normalise(ccFields(name))) + } + } + } + + // The differently-named fields are a deliberate projection, pinned here by hand: + // userId/userName come from the AUTHENTICATED principal (CallContext.user), never from + // a resolved human. Under a consent the principal is the consent's shadow user; the + // human stays on the context as consenter/onBehalfOfUser and is resolved at read time + // via the consent table, never baked into stored rows. + scenario("userId and userName carry the AUTHENTICATED principal, even when consenter and onBehalfOfUser are set") + { + val principal = ResourceUser.create.userId_("principal-user-id").name_("principal-name") + val human = ResourceUser.create.userId_("human-user-id").name_("human-name") + + val light = CallContext( + user = Full(principal), + consenter = Full(human), + onBehalfOfUser = Full(human), + directLoginParams = Map("token" -> "dl-token") + ).toLight + + light.userId should be(Some("principal-user-id")) + light.userName should be(Some("principal-name")) + light.directLoginToken should be("dl-token") + light.partialFunctionName should be("") + } + + scenario("without consent context, userId is simply the authenticated user") + { + val user = ResourceUser.create.userId_("plain-user-id").name_("plain-name") + val light = CallContext(user = Full(user)).toLight + light.userId should be(Some("plain-user-id")) + light.userName should be(Some("plain-name")) + } + } + feature("test CallContext toString secure logging masking") { scenario("toString should mask sensitive data")