From 57b7a3bad9ccd9b4a0b645ec6673ddb54434e481 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 2 Sep 2026 01:00:52 +0200 Subject: [PATCH 1/5] CanCreateEntitlementAtAnyBank stays out of consents. CanCreateEntitlementAtOneBank is allowed --- .../main/scala/code/api/util/APIUtil.scala | 15 ++++++- .../scala/code/api/util/ConsentUtil.scala | 7 ++- .../scala/code/api/util/ErrorMessages.scala | 2 +- .../main/scala/code/api/util/Glossary.scala | 1 + .../scala/code/api/v3_1_0/Http4s310.scala | 13 ++++-- .../scala/code/api/v5_0_0/Http4s500.scala | 8 ++-- .../scala/code/api/v5_1_0/Http4s510.scala | 9 +++- .../scala/code/api/v3_1_0/ConsentTest.scala | 19 ++++++++ .../code/api/v5_0_0/ConsentRequestTest.scala | 23 +++++----- .../code/api/v5_1_0/ConsentObpTest.scala | 44 +++++++++++++++++++ 10 files changed, 120 insertions(+), 21 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index b9b110654c..f96188e05d 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2247,6 +2247,17 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } @deprecated("Use handleAccessControlRegardingEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") + /** + * A consent user (the per-consent principal a Consent-JWT authenticates as) never gets + * just-in-time entitlements. Its roles come from the consent alone: a consent may carry + * CanCreateEntitlementAtOneBank so the agent can grant bank roles to humans, and + * addEntitlement redirects any grant aimed at a consent user to its granting human. Without + * this guard the JIT path would call addEntitlement, see the redirected row as a success, + * and let the consent user through with a role the consent never named. + */ + def isConsentUser(userId: String): Boolean = + Users.users.vend.getUserByUserId(userId).exists(_.isConsentUser) + def hasEntitlement(bankId: String, userId: String, apiRole: ApiRole): Boolean = apiRole match { case RoleCombination(roles) => roles.forall(hasEntitlement(bankId, userId, _)) case role => @@ -2316,7 +2327,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def userHasTheRoles: Boolean = { val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) userHasTheRole || { - getPropsAsBoolValue("create_just_in_time_entitlements", false) && { + getPropsAsBoolValue("create_just_in_time_entitlements", false) && !isConsentUser(userId) && { // If a user is trying to use a Role and the user could grant them selves the required Role(s), // then just automatically grant the Role(s)! (hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) || @@ -2377,7 +2388,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def userHasTheRoles: Boolean = { val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) userHasTheRole || { - getPropsAsBoolValue("create_just_in_time_entitlements", false) && { + getPropsAsBoolValue("create_just_in_time_entitlements", false) && !isConsentUser(userId) && { (hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) || hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) && roles.forall { role => diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index f17d281d67..80df085beb 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1331,6 +1331,12 @@ object Consent extends MdcLoggable { } // 2. Add Roles // Please note that consents can only contain Roles that the User already has access to. + // CanCreateEntitlementAtAnyBank is excluded here as defence in depth. Every create endpoint + // must ALSO reject an explicit request for it with RolesForbiddenInConsent: this filter + // exists so `everything = true` never copies it, not so a named role can vanish silently. + // CanCreateEntitlementAtOneBank is allowed: a consent user cannot be the target of a grant + // and gets no just-in-time entitlements, so the role lets the agent grant bank roles to + // humans without widening its own consent. val allUserEntitlements = Entitlement.entitlement.vend.getEntitlementsByUserId(user.userId).getOrElse(Nil) val entitlements = consent.bank_id match { case Some(bankId) => @@ -1344,7 +1350,6 @@ object Consent extends MdcLoggable { val entitlementsToAdd: Seq[Role] = for { entitlement <- entitlements - if !(entitlement.roleName == canCreateEntitlementAtOneBank.toString()) if !(entitlement.roleName == canCreateEntitlementAtAnyBank.toString()) if consent.everything || consent.entitlements.exists(_ == PostConsentEntitlementJsonV310(entitlement.bankId,entitlement.roleName)) } yield { 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 627dd128d2..629207f760 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -776,7 +776,7 @@ object ErrorMessages { val ConsumerKeyIsInvalid = "OBP-35030: The Consumer Key must be alphanumeric. (A-Z, a-z, 0-9)" val ConsumerKeyIsToLong = "OBP-35031: The Consumer Key max length <= 512" val ConsentHeaderValueInvalid = "OBP-35032: The Consent's Request Header value is not formatted as UUID or JWT." - val RolesForbiddenInConsent = s"OBP-35033: Consents cannot contain the following Roles: ${canCreateEntitlementAtOneBank} and ${canCreateEntitlementAtAnyBank}." + val RolesForbiddenInConsent = s"OBP-35033: Consents cannot contain the following Roles: ${canCreateEntitlementAtAnyBank}." val UserAuthContextUpdateRequestAllowedScaMethods = "OBP-35034: Unsupported as SCA method. " val ConsentIdClaimMissing = "OBP-35035: The access token is not bound to a Consent. The identity provider must include a consent_id claim in access tokens issued via the consent authorisation flow. " val ConsentDoesNotMatchStandard = "OBP-35036: The Consent was created by a different API standard than the endpoint using it. A consent may only be used by endpoints of the standard that created it. " diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 8e71589190..c73732c20f 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -907,6 +907,7 @@ object Glossary extends MdcLoggable { |This speeds up the process of granting of roles. Certain roles are excluded from this automation: | - CanCreateEntitlementAtOneBank | - CanCreateEntitlementAtAnyBank + |Consent users (the principal a Consent-JWT authenticates as) never receive Just in Time Entitlements: their Roles come only from the Consent, even if the Consent carries CanCreateEntitlementAtOneBank. |If create_just_in_time_entitlements is again set to false after it was true for a while, any auto granted Entitlements to roles are kept in place. |Note: In the entitlements model we set createdbyprocess=create_just_in_time_entitlements. For manual operations we set createdbyprocess=manual | diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index 1d96e56c19..2e0aa3a717 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -4420,6 +4420,13 @@ object Http4s310 { case _ => true } } + // Reject CanCreateEntitlementAtAnyBank explicitly (same rule as the consent-request flow). + // createConsentJWT drops it anyway, but silently omitting a requested role is worse + // than a 400: the caller must never believe a consent carries a role it does not. + // CanCreateEntitlementAtOneBank is allowed, see createConsentByConsentRequestId. + _ <- code.util.Helper.booleanToFuture(RolesForbiddenInConsent, cc = Some(cc)) { + !consentJson.entitlements.map(_.role_name).contains(canCreateEntitlementAtAnyBank.toString()) + } myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) _ <- code.util.Helper.booleanToFuture(RolesAllowedInConsent, cc = Some(cc)) { consentJson.entitlements.forall(re => @@ -4590,7 +4597,7 @@ object Http4s310 { BankNotFound, InvalidJsonFormat, ConsentAllowedScaMethods, - RolesAllowedInConsent, + RolesAllowedInConsent, RolesForbiddenInConsent, ViewsAllowedInConsent, ConsumerNotFoundByConsumerId, ConsumerIsDisabled, @@ -4671,7 +4678,7 @@ object Http4s310 { BankNotFound, InvalidJsonFormat, ConsentAllowedScaMethods, - RolesAllowedInConsent, + RolesAllowedInConsent, RolesForbiddenInConsent, ViewsAllowedInConsent, ConsumerNotFoundByConsumerId, ConsumerIsDisabled, @@ -4751,7 +4758,7 @@ object Http4s310 { BankNotFound, InvalidJsonFormat, ConsentAllowedScaMethods, - RolesAllowedInConsent, + RolesAllowedInConsent, RolesForbiddenInConsent, ViewsAllowedInConsent, ConsumerNotFoundByConsumerId, ConsumerIsDisabled, diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index b5fd379833..9511599473 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -1216,10 +1216,12 @@ object Http4s500 { } requestedEntitlements = consentRequestJson.entitlements.getOrElse(Nil) myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) + // CanCreateEntitlementAtAnyBank stays out of consents. CanCreateEntitlementAtOneBank is + // allowed (an agent acting under a consent may grant bank roles to humans): a consent user + // can never be the target of a grant (addEntitlement redirects, the endpoints reject) and + // just-in-time entitlements are disabled for consent users, so the role cannot widen the consent. _ <- Helper.booleanToFuture(RolesForbiddenInConsent, cc = callContextOpt) { - requestedEntitlements.map(_.role_name).intersect( - List(canCreateEntitlementAtOneBank.toString(), canCreateEntitlementAtAnyBank.toString()) - ).isEmpty + !requestedEntitlements.map(_.role_name).contains(canCreateEntitlementAtAnyBank.toString()) } _ <- Helper.booleanToFuture(RolesAllowedInConsent, cc = callContextOpt) { requestedEntitlements.forall(re => diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index fb10602e37..c972fdf70c 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4994,6 +4994,13 @@ object Http4s510 { } } requestedEntitlements = consentJson.entitlements + // Reject CanCreateEntitlementAtAnyBank explicitly (same rule as the consent-request flow). + // createConsentJWT drops it anyway, but silently omitting a requested role is worse + // than a 400: the caller must never believe a consent carries a role it does not. + // CanCreateEntitlementAtOneBank is allowed, see createConsentByConsentRequestId. + _ <- Helper.booleanToFuture(RolesForbiddenInConsent, cc = callContextOpt) { + !requestedEntitlements.map(_.role_name).contains(canCreateEntitlementAtAnyBank.toString()) + } myEntitlements <- Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) _ <- Helper.booleanToFuture(RolesAllowedInConsent, cc = callContextOpt) { requestedEntitlements.forall(re => @@ -5142,7 +5149,7 @@ object Http4s510 { |""", postConsentImplicitJsonV310, consentJsonV310, List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, ConsentAllowedScaMethods, - RolesAllowedInConsent, ViewsAllowedInConsent, ConsumerNotFoundByConsumerId, ConsumerIsDisabled, + RolesAllowedInConsent, RolesForbiddenInConsent, ViewsAllowedInConsent, ConsumerNotFoundByConsumerId, ConsumerIsDisabled, MissingPropsValueAtThisInstance, SmsServerNotResponding, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, diff --git a/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala index 177f7af24e..031bec292b 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala @@ -73,6 +73,7 @@ class ConsentTest extends V310ServerSetup { lazy val bankId = randomBankId lazy val bankAccount = randomPrivateAccount(bankId) lazy val entitlements = List(PostConsentEntitlementJsonV310("", CanGetAnyUser.toString())) + lazy val forbiddenEntitlementAnyBank = List(PostConsentEntitlementJsonV310("", CanCreateEntitlementAtAnyBank.toString())) lazy val views = List(PostConsentViewJsonV310(bankId, bankAccount.id, Constant.SYSTEM_OWNER_VIEW_ID)) def postConsentEmailJsonV310 = SwaggerDefinitionsJSON.postConsentEmailJsonV310 .copy(consumer_id=Some(testConsumer.consumerId.get)) @@ -156,6 +157,15 @@ class ConsentTest extends V310ServerSetup { // Create a consent as the user1. // Must fail because we try to assign a role other that user already have access to the request val request400 = (v3_1_0_Request / "banks" / bankId / "my" / "consents" / "EMAIL").POST <@ (user1) + + // Must fail loudly, never silently drop the role: CanCreateEntitlementAtAnyBank is forbidden in consents + List(forbiddenEntitlementAnyBank).foreach { forbidden => + val responseForbidden = makePostRequest(request400, write(postConsentEmailJsonV310.copy(entitlements = forbidden)), validHeaderConsumerKey) + Then("We should get a 400") + responseForbidden.code should equal(400) + responseForbidden.body.extract[ErrorMessage].message should equal(RolesForbiddenInConsent) + } + val response400 = makePostRequest(request400, write(postConsentEmailJsonV310), validHeaderConsumerKey) Then("We should get a 400") response400.code should equal(400) @@ -234,6 +244,15 @@ class ConsentTest extends V310ServerSetup { // Create a consent as the user1. // Must fail because we try to assign a role other that user already have access to the request val request400 = (v3_1_0_Request / "banks" / bankId / "my" / "consents" / "IMPLICIT").POST <@ (user1) + + // Must fail loudly, never silently drop the role: CanCreateEntitlementAtAnyBank is forbidden in consents + List(forbiddenEntitlementAnyBank).foreach { forbidden => + val responseForbidden = makePostRequest(request400, write(postConsentImplicitJsonV310.copy(entitlements = forbidden))) + Then("We should get a 400") + responseForbidden.code should equal(400) + responseForbidden.body.extract[ErrorMessage].message should equal(RolesForbiddenInConsent) + } + val response400 = makePostRequest(request400, write(postConsentImplicitJsonV310)) Then("We should get a 400") response400.code should equal(400) diff --git a/obp-api/src/test/scala/code/api/v5_0_0/ConsentRequestTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/ConsentRequestTest.scala index d4ae49eba1..3e459be9cb 100644 --- a/obp-api/src/test/scala/code/api/v5_0_0/ConsentRequestTest.scala +++ b/obp-api/src/test/scala/code/api/v5_0_0/ConsentRequestTest.scala @@ -66,7 +66,7 @@ class ConsentRequestTest extends V500ServerSetup with PropsReset{ lazy val entitlements = List(PostConsentEntitlementJsonV310("", CanGetAnyUser.toString())) lazy val bankId = testBankId1.value - lazy val forbiddenEntitlementOneBank = List(PostConsentEntitlementJsonV310(testBankId1.value, CanCreateEntitlementAtOneBank.toString())) + lazy val entitlementOneBank = List(PostConsentEntitlementJsonV310(testBankId1.value, CanCreateEntitlementAtOneBank.toString())) lazy val forbiddenEntitlementAnyBank = List(PostConsentEntitlementJsonV310("", CanCreateEntitlementAtAnyBank.toString())) lazy val accountAccess = List(AccountAccessV500( account_routing = AccountRoutingJsonV121( @@ -262,21 +262,24 @@ class ConsentRequestTest extends V500ServerSetup with PropsReset{ forbiddenRoleResponse.body.extract[ErrorMessage].message should equal (RolesForbiddenInConsent) } - scenario(s"Check the forbidden roles ${CanCreateEntitlementAtOneBank.toString()}", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, ApiEndpoint5, VersionOfApi) { + scenario(s"Check the role ${CanCreateEntitlementAtOneBank.toString()} is allowed in a consent", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, ApiEndpoint5, VersionOfApi) { When(s"We try $ApiEndpoint1 v5.0.0") - val postJsonForbiddenEntitlementAtOneBank = postConsentRequestJson.copy(entitlements = Some(forbiddenEntitlementOneBank)) - val createConsentResponse = makePostRequest(createConsentRequestUrl, write(postJsonForbiddenEntitlementAtOneBank)) + // An agent acting under a consent may grant bank roles to humans, so this role is allowed + // (only CanCreateEntitlementAtAnyBank is forbidden). The user must already hold it. + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateEntitlementAtOneBank.toString) + val postJsonEntitlementAtOneBank = postConsentRequestJson.copy(entitlements = Some(entitlementOneBank)) + val createConsentResponse = makePostRequest(createConsentRequestUrl, write(postJsonEntitlementAtOneBank)) Then("We should get a 201") createConsentResponse.code should equal(201) val createConsentRequestResponseJson = createConsentResponse.body.extract[ConsentRequestResponseJson] val consentRequestId = createConsentRequestResponseJson.consent_request_id - // Role CanCreateEntitlementAtOneBank MUST be forbidden - val forbiddenRoleResponse = makePostRequest(createConsentByConsentRequestIdEmail(consentRequestId), write("")) - Then("We should get a 400") - forbiddenRoleResponse.code should equal(400) - forbiddenRoleResponse.code should equal(400) - forbiddenRoleResponse.body.extract[ErrorMessage].message should equal (RolesForbiddenInConsent) + val allowedRoleResponse = makePostRequest(createConsentByConsentRequestIdEmail(consentRequestId), write("")) + Then("We should get a 201") + allowedRoleResponse.code should equal(201) + // The role must actually be in the consent, not silently dropped + val jwt = allowedRoleResponse.body.extract[ConsentJsonV500].jwt + code.api.util.JwtUtil.getSignedPayloadAsJson(jwt).openOrThrowException("cannot read consent JWT") should include (CanCreateEntitlementAtOneBank.toString) } } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala index ebba60b518..bfcd92aca3 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala @@ -64,6 +64,8 @@ class ConsentObpTest extends V510ServerSetup { lazy val bankId = randomBankId lazy val bankAccount = randomPrivateAccount(bankId) lazy val entitlements = List(PostConsentEntitlementJsonV310("", CanGetAnyUser.toString())) + lazy val entitlementOneBank = List(PostConsentEntitlementJsonV310(bankId, CanCreateEntitlementAtOneBank.toString())) + lazy val forbiddenEntitlementAnyBank = List(PostConsentEntitlementJsonV310("", CanCreateEntitlementAtAnyBank.toString())) lazy val views = List(PostConsentViewJsonV310(bankId, bankAccount.id, Constant.SYSTEM_OWNER_VIEW_ID)) lazy val postConsentEmailJsonV310 = SwaggerDefinitionsJSON.postConsentEmailJsonV310 .copy(entitlements=entitlements) @@ -114,6 +116,15 @@ class ConsentObpTest extends V510ServerSetup { // Create a consent as the user1. // Must fail because we try to assign a role other that user already have access to the request val request = (v5_1_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1) + + // Must fail loudly, never silently drop the role: CanCreateEntitlementAtAnyBank is forbidden in consents + List(forbiddenEntitlementAnyBank).foreach { forbidden => + val responseForbidden = makePostRequest(request, write(postConsentImplicitJsonV310.copy(entitlements = forbidden)), validHeaderConsumerKey) + Then("We should get a 400") + responseForbidden.code should equal(400) + responseForbidden.body.extract[ErrorMessage].message should equal(RolesForbiddenInConsent) + } + val response = makePostRequest(request, write(postConsentImplicitJsonV310), validHeaderConsumerKey) Then("We should get a 400") response.code should equal(400) @@ -178,4 +189,37 @@ class ConsentObpTest extends V510ServerSetup { responseGetUserByUserId.body.extract[ErrorMessage].message should include(ConsentDisabled) } } + + feature(s"$CreateConsent version $VersionOfApi - a consent may carry CanCreateEntitlementAtOneBank, and Just in Time Entitlements never widen it") { + scenario("A consent user holding CanCreateEntitlementAtOneBank gets no just-in-time roles", CreateConsent, AnswerConsentChallenge, VersionOfApi) { + setPropsValues("consents.allowed" -> "true", "consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE", "create_just_in_time_entitlements" -> "true") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateEntitlementAtOneBank.toString) + + When("We create a consent that carries CanCreateEntitlementAtOneBank") + val request = (v5_1_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1) + val created = makePostRequest(request, write(postConsentImplicitJsonV310.copy(entitlements = entitlementOneBank)), validHeaderConsumerKey) + Then("We should get a 201") + created.code should equal(201) + val consentId = created.body.extract[ConsentJsonV310].consent_id + val jwt = created.body.extract[ConsentJsonV310].jwt + + And("We answer the SCA challenge") + val answerConsentChallengeRequest = (v5_1_0_Request / "banks" / bankId / "consents" / consentId / "challenge").POST <@ (user1) + val answered = makePostRequest(answerConsentChallengeRequest, write(PostConsentChallengeJsonV310(answer = Consent.challengeAnswerAtTestEnvironment))) + answered.code should equal(201) + val header = List((RequestHeader.`Consent-JWT`, jwt)) ::: validHeaderConsumerKey + + Then("The consent user holds the role the consent names") + val current = makeGetRequest((v5_1_0_Request / "users" / "current").GET, header) + current.code should equal(200) + val user = current.body.extract[UserJsonV300] + user.user_id should not equal (resourceUser1.userId) + user.entitlements.list.map(e => PostConsentEntitlementJsonV310(e.bank_id, e.role_name)) should contain (entitlementOneBank.head) + + And("A role the consent does not name is refused even though the user could grant it: JIT is off for consent users") + val metrics = makeGetRequest((v5_1_0_Request / "management" / "metrics" / "banks" / bankId).GET, header) + metrics.code should equal(403) + metrics.body.extract[ErrorMessage].message should include (UserHasMissingRoles) + } + } } From 2d86f4e9e12b5d8c56d33e5c730ce1b154070b33 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 2 Sep 2026 10:47:32 +0200 Subject: [PATCH 2/5] onBehalfOfUserId Phase 0. See ON_BEHALF_OF_USER_ID_PLAN.md --- ON_BEHALF_OF_USER_ID_PLAN.md | 337 ++++++++++++++++++ .../docs/introductory_system_documentation.md | 4 +- .../main/scala/code/api/util/ApiSession.scala | 58 +-- .../scala/code/api/util/ConsentUtil.scala | 20 +- .../MigrationOfActivityDashboardIndexes.scala | 4 +- .../scala/code/api/v2_0_0/Http4s200.scala | 2 +- .../scala/code/api/v2_2_0/Http4s220.scala | 2 +- .../scala/code/api/v3_0_0/Http4s300.scala | 2 +- .../scala/code/api/v3_1_0/Http4s310.scala | 2 +- .../scala/code/api/v4_0_0/Http4s400.scala | 4 +- .../scala/code/api/v5_0_0/Http4s500.scala | 4 +- .../scala/code/api/v5_1_0/Http4s510.scala | 6 +- .../scala/code/api/v6_0_0/Http4s600.scala | 13 +- .../scala/code/api/v7_0_0/Http4s700.scala | 21 +- .../LocalMappedConnectorInternal.scala | 4 +- .../main/scala/code/metrics/APIMetrics.scala | 2 +- .../scala/code/metrics/MappedMetrics.scala | 2 +- .../code/model/dataAccess/ResourceUser.scala | 10 +- .../MappedTransactionRequestProvider.scala | 2 +- .../src/main/scala/code/users/LiftUsers.scala | 12 +- obp-api/src/main/scala/code/users/Users.scala | 4 +- .../code/api/util/AgentDelegationTest.scala | 18 +- .../api/v5_1_0/ConsentOwnershipTests.scala | 2 +- .../test/scala/code/util/ApiSessionTest.scala | 12 +- .../commons/model/UserModel.scala | 2 - 25 files changed, 434 insertions(+), 115 deletions(-) create mode 100644 ON_BEHALF_OF_USER_ID_PLAN.md diff --git a/ON_BEHALF_OF_USER_ID_PLAN.md b/ON_BEHALF_OF_USER_ID_PLAN.md new file mode 100644 index 0000000000..7331707fbc --- /dev/null +++ b/ON_BEHALF_OF_USER_ID_PLAN.md @@ -0,0 +1,337 @@ +# On-behalf-of user id — making ownership-by-the-human automatic + +Written 2026-09-02 evening, for pickup 2026-09-03. This is the only document: no separate +checklist. Track progress here by marking items done in place. **Status: Phase 0 done 2026-09-02 (uncommitted): clean build green, `AgentDelegationTest` / +`ApiSessionTest` / `ConsentOwnershipTests` all pass (21 tests). Phase 1 next.** Background and the reasoning are on the Portal page `/developers/opey-permissions` +(OBP-Frontend, uncommitted) and in `OBP-Frontend/CONSENT_ESCALATION_GAP.md`. + +Working rules: the user commits, the assistant never does. The provider is the mechanism; +endpoint uses of `cc.onBehalfOfUserId` are clarity only, not the fix. A resolver WARN firing in +tests means a site forgot the rule or chose the wrong reference. Single-suite mvn command (clean build is required in this checkout, ~10 min, run detached): +`MAVEN_OPTS="-Xss128m -Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" mvn -pl obp-api -am clean test -DwildcardSuites=code.api.util.AgentDelegationTest,code.util.ApiSessionTest`. + +## Vocabulary (settled 2026-09-02 — use these words and no others) + +| term | code | meaning | +|---|---|---| +| **user** | `userId` / `user_id` | the authenticated caller, whatever it is: a logged-in human, a consent user, an agent. Its entitlements and views are what OBP checks. Unchanged. | +| **on-behalf-of user** | `onBehalfOfUserId` / `on_behalf_of_user_id` | the human the user acts for; owner of anything durable the call creates. Equals `userId` when a human acts for themselves. | +| **consent user** | `User.isConsentUser`, `ResourceUser.CreatedByConsentId` | a user row created by a consent; its on-behalf-of user is the consent's `userId`. A durable agent is a consent user with a long-lived consent — there is no other kind of agent. | +| **original user** | `User.isOriginalUser` (= `CreatedByConsentId` empty), already in the commons `User` trait next to `isConsentUser` | a user OBP did not mint as a stand-in for someone else. Says nothing about whether a person or a service account is behind the login — OBP cannot know that without KYC and does not claim to. | + +Invariant: **an on-behalf-of user must have `isOriginalUser` true.** One hop, no chains. This is a check on +one column OBP writes itself, not an inference about natural persons. `IsNaturalPerson` and +`PrincipalUserId` are dropped as concepts (never set, never read; see Phase 0): the on-behalf-of +relationship has exactly one record, the consent. + +Words retired: *accountable user, principal, shadow, actor / acting user, human user, granting +human, real / effective identity.* "Principal" in particular means the authenticated identity to +a security engineer and the party-acted-for to an agent framework, i.e. opposite ends; do not +reintroduce it. `consenter` (BG/UK) and `consentCreator` (OBP-native) survive only as the names of +the two *sources* the request layer reads the on-behalf-of user from. + +`on_behalf_of` already means exactly this role everywhere it appears in OBP today +(`MappedTransactionRequest.mOnBehalfOfUserId`, v6 `on_behalf_of_user_id`, `CallContext.onBehalfOfUser`), +so no clash. AI agents will not use Berlin Group endpoints for the foreseeable future; BG/UK +consents only need to keep working, not to be designed for. + +## The model we are going to + +Every request under a consent carries three identities, one job each: + +| identity | job | today | +|---|---|---| +| **user** (here: the consent user) | authenticates the call; its embedded entitlements/views are what OBP checks; recorded as `user_id` in metrics | correct, keep | +| **consent_reference_id** | on every metric row; resolves user → on-behalf-of user and → exact granted scope | correct, keep | +| **on-behalf-of user** | owner/creator/holder/target of anything durable the call creates for a person | manual per endpoint; wrong by default | + +Goal: the persistence layer defaults durable user references to the on-behalf-of user, so +endpoints are correct without remembering. Authorisation stays on the consent user (ConsentUtil's +isolation comment, ~line 1195, explains why act-as-human is not an option; +`experimental_become_user_that_created_consent` stays deprecated). + +## What already exists (reuse, don't duplicate) + +1. `CallContext.accountableUserId` (`ApiSession.scala:244`): `onBehalfOfUser.or(consenter)` else DB chain `ResourceUser.CreatedByConsentId → MappedConsent.userId` else self. **Phase 0 renames it → `onBehalfOfUserId`.** +2. `CallContext.humanUser` (`ApiSession.scala:118`) = `onBehalfOfUser.or(consenter).or(user)`; 3 readers (`Http4s510:4720`, `ApiSession:126`, a comment in `ConsentUtil:1941`). **Phase 0 renames it → `onBehalfOfUser`.** +3. `CallContext.onBehalfOfUser` field (`ApiSession.scala:43`): the OBP-native consent's *creator*, from the JWT `createdByUserId`. A source, not the resolved value. **Phase 0 renames it → `consentCreator`** so the resolved method can take the name. +4. `CallContext.consenter` (`ApiSession.scala:51`): the PSU who authorised a BG/UK consent, from `consent.userId`. A source. Keep the name. +5. `MappedEntitlements.addEntitlement` (`MappedEntitlements.scala:161`): inline copy of the same chain; redirects untagged grants to the on-behalf-of user; exemption `createdByProcess == Constant.consent_user`. +6. `APIUtil.isConsentUser(userId)` (`APIUtil.scala:2258`) and `User.isConsentUser` (commons `UserModel.scala:70`, `= createdByConsentId.nonEmpty`). +7. `ResourceUser.PrincipalUserId` + `IsNaturalPerson` (`ResourceUser.scala:98-103`, added 2026-03-07): **never set, never read** — only plumbed through `createResourceUser`, no caller passes them, no reader outside the accessors. Every row has the defaults. **Both dropped in Phase 0.** `PrincipalUserId` would have been a second, denormalised record of the on-behalf-of relationship for consent-less agents; there are no consent-less agents (an agent's scope *is* a consent), so the consent chain is the only record. +8. `User.isOriginalUser` (commons `UserModel.scala:69`, `= createdByConsentId.isEmpty`): already the predicate the invariant uses; not touched. +9. `MappedTransactionRequest.mOnBehalfOfUserId` (`MappedTransactionRequestProvider.scala:172,299`): precedent for a "record both" table (`mUserId` + `mOnBehalfOfUserId`). +10. Tests: `AgentDelegationTest` (resolver chain, 113 lines), `FrozenClassTest` (pattern for "every X must be listed"), `ConsentObpTest`. + +## Phase 0 — renames and drops (mechanical, one commit, no behaviour change except row 8) — **done 2026-09-02, tests green** + +Phase 0 stands alone: it can ship without Phase 1 and leaves the code consistent. Purpose: make +the code speak the vocabulary above before any new code is written, so Phase 1 is not built on +names it then has to rename. Everything here is a drop of something never set, or a +rename of an accessor, plus one documentation fix. No schema migration. + +| # | today | after | where | notes | +|---|---|---|---|---| +| 1 ✅ | `ResourceUser.PrincipalUserId`, `User.principalUserIdOption`, `createResourceUser(…, principalUserId)` | *dropped* | `ResourceUser.scala:101,143`, commons `UserModel.scala:75`, `Users.scala:86`, `LiftUsers.scala:323,362` | never set, never read, no caller passes it; the consent chain is the only record of on-behalf-of | +| 2 ✅ | `ResourceUser.IsNaturalPerson`, `User.isNaturalPerson`, `createResourceUser(…, isNaturalPerson)` | *dropped* | `ResourceUser.scala:98,142`, commons `UserModel.scala:74`, `Users.scala:85`, `LiftUsers.scala:322,358` | never set, never read | +| 3 ✅ | DB columns `resourceuser.principaluserid`, `resourceuser.isnaturalperson` | left in place | any DB started since 2026-03-07 | Mapper never drops columns; harmless (null / true). Drop by hand when convenient: `ALTER TABLE resourceuser DROP COLUMN principaluserid; ALTER TABLE resourceuser DROP COLUMN isnaturalperson;` | +| 4 ✅ | glossary `isNaturalPerson`, `principalUserId` | one `on_behalf_of_user_id` entry | `docs/introductory_system_documentation.md:4316,4334` | | +| 5 ✅ | `CallContext.onBehalfOfUser` (field) | `consentCreator` | `ApiSession.scala:43`; set `ConsentUtil.scala:574`; read `ConsentUtil.scala:583-588`, `Http4s600.scala:207-208`, `Http4s700.scala:950-951`, `MappedTransactionRequestProvider.scala:172`, tests `ApiSessionTest.scala:145`, `AgentDelegationTest.scala:109` | it holds the OBP-consent *creator*, a source, not the resolved value | +| 6 ✅ | `CallContext.humanUser` | `onBehalfOfUser` | `ApiSession.scala:118,126`; `Http4s510.scala:4716,4720`; comments `ConsentUtil.scala:1941`, `ConsentOwnershipTests.scala:51` | the resolved `Box[User]`: `consentCreator.or(consenter).or(user)` | +| 7 ✅ | `CallContext.accountableUserId` | `onBehalfOfUserId` | `ApiSession.scala:244` + 20 endpoint/connector sites + `AgentDelegationTest` (7) + comments in `MappedMetrics`, `APIMetrics`, `MigrationOfActivityDashboardIndexes`, `ResourceUser.scala:152` | body unchanged in Phase 0; Phase 1 makes it delegate to the resolver | +| 8 ✅ | v6/v7 `/users/current` JSON field `on_behalf_of` reads the `consentCreator` field only (null for BG/UK consents) | reads `consentCreator.or(consenter)` — the delegated value, **not** the resolved `onBehalfOfUser`, whose `.or(user)` fallback would show a plain user as their own on-behalf-of | `Http4s600.scala:174-213`, `Http4s700.scala:949-956`; endpoint comment ("impersonation headers", stale; fixed). No resource-doc text mentions the field, nothing to change there | **optional, not needed by Phase 1.** Additive behaviour change: BG/UK consent callers get the consenter instead of null; everyone else unchanged (Decisions 8); release-note it | +| 9 ✅ | — | **checked 2026-09-02: yes, always the same user.** | `MappedConsent.scala:205,232,279`; `ConsentUtil.scala:1359,1457,1647`; create endpoints `Http4s310:4451`, `Http4s500:1265`, `Http4s510:5025` | An OBP consent names its user twice: the row column `mUserId` and the JWT claim `createdByUserId`. OBP-native: all three create endpoints pass the logged-in `user` to both. BG/UK: both empty at creation, both set to the authorising user at authorisation (`updateConsentUser` + `updateUserIdOfBerlinGroupConsentJWT`). So `CallContext`'s two sources (`onBehalfOfUser` field from the claim, `consenter` from the column) always carry one value. **Decided: keep the two source fields separate anyway** (`consentCreator`, `consenter`) — explicit about where each came from; the resolved `onBehalfOfUser` (row 6) is the one to read. | + +Not renamed: local `val humanUserId = cc.onBehalfOfUserId` in the createBank endpoints (`Http4s220:471`, `Http4s500:469`, `Http4s600:877`) and `Http4s700.humanAndAgentUserIds` — locals, Phase 3 touches those endpoints anyway; `consenter` (a source, name is accurate), `User.isConsentUser` / `Constant.consent_user` +(the kind of user), `mOnBehalfOfUserId` and `on_behalf_of_user_id` (already right). The ABAC rule +engine's `onBehalfOfUser` parameter (`AbacRuleEngine.scala:33,164`) is a separate rule-input slot, +always `None` today; leave it, it already uses the right word. + +Done when (both satisfied 2026-09-02): `grep -rn "PrincipalUserId\|principalUserId\|IsNaturalPerson\|isNaturalPerson\|humanUser\b\|accountableUserId" obp-api/src obp-commons/src` is empty and `AgentDelegationTest`, `ApiSessionTest`, `ConsentOwnershipTests` pass. + +## Phase 1 — one resolver, policy-aware entry point (decided: lives in `Users`) + +Resolver home: trait `code.users.Users`, impl `LiftUsers`. Chosen over a separate object because +`LiftUsers` is the only writer of `CreatedByConsentId`, it +is already injectable (`Users.users.vend`, 156 call sites), and it already imports consent code, so +no new dependency edge. A separate `code..` object was rejected on naming: every such object +with a `vend` is a table-backed provider and the name would read as a new table. + +```scala +// ---- the raw chain, no policy ---------------------------------------------------------- +/** The on-behalf-of user for `userId`. + * consent user → the consent's userId (authoritative, read at call time: BG/UK consents + * bind their human only at authorisation, so don't copy it at creation) + * original user → userId unchanged + * Fails closed: unknown user / dangling consent id / empty human → userId unchanged (+ WARN). + * Invariant: the result row is an original user (isOriginalUser); a consent user whose consent names + * another consent user is a data bug (WARN + Failure — the only case that cannot fall back). + * Takes only the id on purpose: nothing request-asserted (body/header/query) can steer it. */ +def onBehalfOfUserIdOf(userId: String): Box[String] + +/** True when `userId` acts for itself and may own durable state. */ +def actsForSelf(userId: String): Boolean = onBehalfOfUserIdOf(userId).exists(_ == userId) + +// ---- what a provider gets back: everything it should store, plus the log line ----------- +case class Attribution( + userId: String, // the authenticated caller + onBehalfOfUserId: String, // who owns what it creates; == userId for a human acting alone + consentId: Option[String], // the consent behind a consent user, if any + ref: UserReference // the column this was computed for; carried for logging +) { + def isDelegated: Boolean = userId != onBehalfOfUserId + /** the single value for the column `ref` names, per its policy */ + def userIdToStore: String = ref.policy match { + case KeepUserId => userId + case UseOnBehalfOfUserId => onBehalfOfUserId + case Reject => userId // unreachable: attributionOf fails first + } +} + +// ---- the entry point providers actually call --------------------------------------------- +/** Attribution for writing column `ref` as `userId`. Applies `ref.policy`: + * KeepUserId / UseOnBehalfOfUserId → Full(attribution), WARN naming `ref` when isDelegated + * Reject → Full(attribution) if !isDelegated, else Failure(InvalidUserId … names a consent user) */ +def attributionOf(userId: String, ref: UserReference): Box[Attribution] + +/** Convenience for single-column writers. */ +def attributedUserId(userId: String, ref: UserReference): Box[String] = attributionOf(userId, ref).map(_.userIdToStore) +``` + +`UserReference` is the Phase-2 policy file as code (main tree, see Phase 2). The `ref` argument is +chosen by provider code, never from the request, so the "no caller-asserted input" property of +`onBehalfOfUserIdOf` still holds. `consentId` is derived inside the resolver, never passed in, for +the same reason. + +Implementation notes: + +1. `onBehalfOfUserIdOf` = the chain `addEntitlement` and `CallContext.accountableUserId` both + inline today (`ResourceUser.find(By(userId_)) → CreatedByConsentId → getConsentByConsentId → + consent.userId`). Both then delegate to it; CallContext + keeps its `consentCreator.or(consenter)` precedence in front. +2. Cache (decided): `Caching.memoizeSyncWithImMemory` (Guava via scalacache, already used), + key `onBehalfOfUserIdOf:`, TTL 10 min. Memoise humans (answer = self) and bound + consent users. Do **not** memoise the "consent user whose consent names no human yet" branch, + or a BG consent bound a minute later stays pinned to the consent user for the TTL. +3. Agents (decided, see Decisions 4): an agent is a consent user; there is no consent-less + agent and no second column. The resolver is one hop (no chains) and asserts the target row + `isOriginalUser`; if not, WARN and `Failure`. The `Reject` policy on consent creation by a + consent user is what keeps chains from ever being written. +4. Every delegated attribution logs WARN with the `ref` name, `userId`, `onBehalfOfUserId`, + `consentId`. A WARN firing in tests means a site chose the wrong reference or a policy is wrong. +5. **Check before coding**: for OBP-native consents, `CallContext` prefers the JWT's + `createdByUserId` (`consentCreator`) while the resolver follows `consent.userId`. Same person + when a human creates their own consent in the Portal; verify no creation path sets `mUserId` + to someone other than the creator (`MappedConsentProvider.scala:54,205,232,279`). If one does, + decide which wins and write it down here. + +Call sites after Phase 1: + +```scala +// ApiSession.scala +def onBehalfOfUser: Box[User] = consentCreator.or(consenter).or(user) // was humanUser +def onBehalfOfUserId: String = // was accountableUserId + consentCreator.or(consenter).map(_.userId).filter(_.nonEmpty) + .openOr(Users.users.vend.onBehalfOfUserIdOf(user.map(_.userId).openOr(""))) + +// MappedEntitlements.addEntitlement: the magic-string exemption becomes a reference choice +val ref = if (createdByProcess == Constant.consent_user) UserReference.ConsentEntitlementUser + else UserReference.EntitlementUser +for { targetUserId <- Users.users.vend.attributedUserId(userId, ref); ... } + +// MappedTransactionRequestProvider: a record-both table, one call, two columns +for { a <- Users.users.vend.attributionOf(userId, UserReference.TransactionRequest) } yield + tr.mUserId(a.userId).mOnBehalfOfUserId(a.onBehalfOfUserId) +``` + +## Phase 2 — assign an attribution policy to every user-reference column (from a grep of Mapped classes) + +Rule: **the agent owns nothing durable.** Only the consent's own authorisation rows stay on the consent user. + +Every user-reference column gets exactly one **attribution policy**, which says what value the +column takes when the user is a consent user (or an agent user with an on-behalf-of user): + +| policy | meaning | +|---|---| +| `KeepUserId` | the authenticated user's own id; no resolver | +| `UseOnBehalfOfUserId` | the on-behalf-of user's id, via the resolver in the provider | +| `Reject` | the request is refused with 400 | + +"Record both" below is a table-level description: one `KeepUserId` column and one +`UseOnBehalfOfUserId` column on the same row. Such tables make one `attributionOf` call with a +table-level reference and write both fields of the `Attribution`. + +The policy file is **main-tree Scala**, because `Users.attributionOf` reads it at runtime +(proposed: `obp-api/src/main/scala/code/users/UserReference.scala`): + +```scala +sealed trait AttributionPolicy +object AttributionPolicy { + case object KeepUserId extends AttributionPolicy + case object UseOnBehalfOfUserId extends AttributionPolicy + case object Reject extends AttributionPolicy +} + +/** One value per user-reference column (or per record-both table). Naming: . */ +sealed abstract class UserReference(val policy: AttributionPolicy, val mapper: Class[_], val fields: List[String]) +object UserReference { + case object AccountAccessUser extends UserReference(KeepUserId, classOf[AccountAccess], List("user")) + case object ConsentEntitlementUser extends UserReference(KeepUserId, classOf[MappedEntitlement], List("mUserId")) // createdByProcess == consent_user + case object EntitlementUser extends UserReference(UseOnBehalfOfUserId, classOf[MappedEntitlement], List("mUserId")) + case object AccountHolderUser extends UserReference(UseOnBehalfOfUserId, classOf[MapperAccountHolders], List("user")) + case object TransactionRequest extends UserReference(UseOnBehalfOfUserId, classOf[MappedTransactionRequest], List("mUserId", "mOnBehalfOfUserId")) // record both + case object ConsentCreator extends UserReference(Reject, classOf[MappedConsent], List("mUserId")) + case object OAuthConsumerCreator extends UserReference(Reject, classOf[Consumer], List("createdByUserId")) + // … one per row of the tables below + val all: List[UserReference] = List(...) // the frozen test walks this +} +``` + +Carrying `mapper` + `fields` on each value is what lets the Phase-5 frozen test tie every +reflected Mapper column to exactly one reference (one column may have two references only when +they differ by process, as `MappedEntitlement.mUserId` does). + +### KeepUserId — authorisation materialisation, NO resolver +| # | class | field | note | +|---|---|---|---| +| 1 | `views/system/AccountAccess` | user id | views copied from the JWT each request; ALL_CONSUMERS rows; has lifecycle GC | +| 2 | `entitlement/MappedEntitlements` | `mUserId` **only when** `createdByProcess == consent_user` | existing exemption | +| 3 | `model/dataAccess/ResourceUser` | itself | the consent user's own row | +| 4 | `userlocks/UserLocks` | `UserId` | lock the user (a consent user never logs in; effectively unused) | +| 5 | `transactionChallenge/MappedExpectedChallengeAnswer` | `ExpectedUserId` | challenge is answered by the initiating user | +| 6 | `chat/MappedChatMessage` | `SenderUserId` | sender = the user is truthful; `MentionedUserIds` are humans by construction | +| 7 | `api/pemusage/MappedPemUsage` | `LastUserId` | audit | + +### Record both — `KeepUserId` column + `UseOnBehalfOfUserId` column on one row +| # | class | user field | on-behalf-of field | action | +|---|---|---|---|---| +| 8 | `metrics/MappedMetrics` | `userId` | via `consent_reference_id` | none | +| 9 | `metrics/ConnectorTrace` | `userId` | via consent ref | none | +| 10 | `transactionrequests/MappedTransactionRequestProvider` | `mUserId` | `mOnBehalfOfUserId` | make `mOnBehalfOfUserId` use the resolver (today `onBehalfOfUser.or(consenter)` only — misses the DB chain) | +| 11 | `entitlement/MappedEntitlements` | `mGrantedByUserId` (audit: who granted) | `mUserId` (target, redirected) | none | + +### UseOnBehalfOfUserId — ownership / attribution, resolver in the provider's create/link +| # | class | field(s) | provider entry point to guard | +|---|---|---|---| +| 12 | `accountholders/MapperAccountHolders` | `user` FK | `getOrCreateAccountHolder(user, …)` (:39) — resolve `user` first | +| 13 | `usercustomerlinks/MappedUserCustomerLink` | `mUserId` | `createUserCustomerLink(userId, …)` (:14) | +| 14 | `accountapplication/MappedAccountApplication` | `mUserId` | create (v3.1 endpoint already guards; make provider default) | +| 15 | `accountaccessrequest/AccountAccessRequest` | `RequestorUserId`, `TargetUserId`, `CheckerUserId` | create + approve (v6 endpoints already guard target) | +| 16 | `entitlementrequest/MappedEntitlementRquests` | `mUserId` | create (v3.0 endpoint resolves already) | +| 17 | `apicollection/ApiCollection` | `UserId` | create | +| 18 | `users/MappedUserAttribute` | `UserId` | create/update | +| 19 | `users/UserAgreement`, `users/UserInitAction` | `UserId` | create | +| 20 | `context/MappedUserAuthContext`, `…Update` | `mUserId` | create (consent copies the human's contexts into ConsentAuthContext separately — that path is fine) | +| 21 | `dynamicEntity/*` (3), `dynamicEndpoint/*`, `dynamicResourceDoc`, `dynamicMessageDoc`, `connectormethod/ConnectorMethod`, `abacrule/AbacRuleTrait` | `UserId` / `CreatedByUserId` / `UpdatedByUserId` | create/update | +| 22 | `metadata/counterparties/MapperCounterparties` | `mCreatedByUserId` | create | +| 23 | `model/dataAccess/MappedBank` | `CreatedByUserId` | create (creator-grant already resolved at endpoint) | +| 24 | `organisation/Organisation`, `payeelookup/PayeeLookup`, `routingscheme/RoutingScheme`, `utilitypayment/UtilityPaymentCallback` | `CreatedByUserId` | create | +| 25 | `standingorders/MappedStandingOrder` | `UserId` | create | +| 26 | `mandate/MandateTrait` | `CreatedByUserId`, `UpdatedByUserId`, `UserIds` | create/update | +| 27 | `webhook/*` (3) | `CreatedByUserId` / `mCreatedByUserId` | create | +| 28 | `chat/MappedChatRoom`, `MappedParticipant`, `MappedReaction`, `ChatEmailDigestState` | `CreatedByUserId` / `UserId` | create (Portal chat: a human's room, participation, reaction) | +| 29 | `crm/MappedCrmEventProvider` | `mUserId` | create | +| 30 | `kyccheck` `mStaffUserId`, `meetings` `mStaffUserId`/`mCustomerUserId` | | create (staff = human operator) | + +### Reject — a consent user must not do this at all +| # | class | why | +|---|---|---| +| 31 | `consent/MappedConsent.mUserId` (consent creating a consent) | nested delegation; 400 at the create endpoints | +| 32 | `model/OAuth.createdByUserId` (tokens/consumers minted by a consent user) | credentials outlive the consent; 400 | + +Phase-2 deliverable: `UserReference.scala` in the **main** tree, one case object per row of the tables above, `all` listing them. Not a database table, and not these markdown tables: the markdown is the working draft, the Scala file is what runs (via `Users.attributionOf`) and what `UserReferenceAttributionPolicyTest` (Phase 5) checks. + +## Phase 3 — provider guards (UseOnBehalfOfUserId) + +Pattern, one line at the top of each create/link method, naming the column being written: + +```scala +for { + ownerId <- Users.users.vend.attributedUserId(userId, UserReference.AccountHolderUser) // WARNs when delegated + ... +``` + +Providers that return a plain value rather than a `Box` either grow a `Box` (preferred) or +`openOr(userId)` with a comment. Both ways to be wrong — forgetting the call, or naming the wrong +reference — are caught by the Phase-5 sweep; the second is also visible in review. + +1. Providers that take a `User` (AccountHolders): resolve to id, re-fetch the on-behalf-of `User` once (cached). +2. Keep endpoint-level `cc.onBehalfOfUserId` uses; they become redundant clarity, not the mechanism. +3. `KeepUserId` writers that share a provider method with a `UseOnBehalfOfUserId` path (views materialiser, consent entitlements) pass a different `UserReference` (e.g. `ConsentEntitlementUser` vs `EntitlementUser`); no more string-typed exemptions. + +Order of attack (highest strand-risk first): AccountHolders → UserCustomerLink → AccountApplication → UserAuthContext → ApiCollection/UserAttribute → the rest mechanically. + +## Phase 4 — explicit-target guards (endpoint 400s) + +Doctrine (settled 2026-09-01): implicit self → redirect in provider; explicit `USER_ID` naming a consent user → 400 `InvalidUserId … names a consent user`. Already done: addEntitlement (v2.0/v7), addUserToGroup (v6), createAccount (v2.0/v3.1/v4.0/v5.0/v7), grantUserAccessToViewById (v5.1), account access requests (v6), account applications (v3.1). To sweep: createUserCustomerLink, API collections, user attributes, auth contexts, KYC/meeting staff ids, webhooks with explicit ids. `Reject` columns refuse in the provider (`attributionOf` returns Failure); endpoints map that to 400 and may keep an early explicit check for a nicer message, but the floor holds without them. + +## Phase 5 — tests + +1. **`AgentDelegationTest`** — extend: `onBehalfOfUserIdOf` for original user / consent user / dangling consent (fails closed) / cache hit after consent later bound (BG case) / consent whose user is itself a consent user → Failure; `attributionOf` for each of the three policies. +2. **`UserReferenceAttributionPolicyTest`** (frozen-style, like `FrozenClassTest`): iterate `ToSchemify.models`, reflect Mapper fields whose name matches `(?i)userid|createdby|grantedby|holder`, assert every (class, field) is named by at least one `UserReference` in `UserReference.all`, and by more than one only where the references differ by process; assert every `UserReference` names real Mapper fields. New tables and renamed columns fail until sorted. +3. **`OnBehalfOfOwnershipSweepTest`**: mint a consent for a test human with generous roles; call every `UseOnBehalfOfUserId` create endpoint with the consent JWT; assert no row in any such table references the consent user's id, and at least one references the human. Also assert `Reject` endpoints return 400. +4. Existing `ConsentObpTest` / `ConsentTest` keep passing (35033 now only AnyBank). + +## Phase 6 — follow-through + +1. Portal page `/developers/opey-permissions`: shrink "Attribution Is Not Yet Universal" to one line once the sweep test is green; use the vocabulary above there too. +2. Memory: write `on-behalf-of-user-id-plan` (none exists yet) pointing at this file, then mark built. +3. Optional later: consent revocation GC for consent-user rows (`KeepUserId`) — still declined for now. + +## Decisions (settled 2026-09-02) + +1. Vocabulary: `user_id` (authenticated caller, unchanged) and `on_behalf_of_user_id` (the human acted for). See the table at the top for what each retired word maps to. +2. Drops and renames that follow: drop `ResourceUser.PrincipalUserId` and `ResourceUser.IsNaturalPerson` (never set; a consent-less agent does not exist, so the consent chain is the only on-behalf-of record); `CallContext.onBehalfOfUser` field → `consentCreator`; `CallContext.humanUser` → `onBehalfOfUser`; `CallContext.accountableUserId` → `onBehalfOfUserId`; `AccountableOwnershipSweepTest` → `OnBehalfOfOwnershipSweepTest`. +3. Resolver home: `Users` trait + `LiftUsers`. See Phase 1 for why not a separate object. +4. **Invariant: the on-behalf-of user is always an original user** — `isOriginalUser`, i.e. `CreatedByConsentId` empty (in 2026; reversing this would be a deliberate decision, not a default). Three rules make it so: (a) the `Reject` policy: a consent user cannot create a consent, so no consent ever names a consent user; (b) the resolver is one hop and checks `isOriginalUser` on the row it lands on — a non-original target is a data bug: WARN and `Failure`, not fall back to the caller; (c) therefore every consent user, durable agents included, has an original user behind it, and "agents own nothing durable" is a corollary. `IsNaturalPerson` is dropped: OBP cannot know whether a person or a service account is behind an IdP login without KYC, and will not pretend to. "Original user" is structural and says nothing about persons. +5. Policy-aware entry point: providers call `attributionOf(userId, ref)` and name the column; the policy decides; the returned `Attribution` carries everything the caller should store and is the one place delegation is logged. Record-both tables are one reference with two fields. +6. Cache: in-memory Guava via `Caching.memoizeSyncWithImMemory`, 10 min TTL, never memoise the not-yet-bound consent case. +7. The `Reject` policy covers consent creation and OAuth consumer/token creation by a consent user. +8. v6/v7 `/users/current` JSON field `on_behalf_of`: today set only for OBP-native consents (from the JWT creator), null for BG/UK consents although they have a human. After Phase 0 row 8 it reads `consentCreator.or(consenter)`, so BG/UK consent callers get the consenter too; plain users and OBP-consent callers see no change. It must not read the resolved `onBehalfOfUser`, whose `.or(user)` fallback would show every plain user as their own on-behalf-of. Optional; not needed by Phase 1. Accepted as correct; note in the release notes. + +## Risks + +1. **Silent redirects hide bugs** → WARN on every delegated attribution + the sweep test; redirects are the net, endpoints stay explicit. +2. **BG/UK consents with no human yet**: resolver returns the consent user (fails closed); those flows don't create `UseOnBehalfOfUserId` objects before authorisation — verify in the sweep. +3. **Delete/lookup asymmetry**: rows are on the on-behalf-of user, so "delete by consent user id" finds nothing — acceptable because explicit targets are rejected. +4. **Perf**: one cached read per write for consent callers only. diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index 462d4cb969..150dc01f7a 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -4313,8 +4313,6 @@ docker run -p 8080:8080 \ **Consent:** Permission granted by user for data access -**isNaturalPerson:** Boolean field on User that distinguishes human users (true, default) from service accounts/machine users (false) - **Mandate:** Formal agreement between a corporate customer and a bank defining who can operate an account, what they can do, and under what conditions **Direct Login:** Username/password authentication method @@ -4331,7 +4329,7 @@ docker run -p 8080:8080 \ **Opey:** AI-powered banking assistant -**principalUserId:** Optional field on User that links a service/agent user back to the human principal it acts on behalf of, formalising the Human Agent delegation chain +**on_behalf_of_user_id:** The user a request is made on behalf of: for a consent user (a User row created by a Consent, `created_by_consent_id` set) it is the Consent's user; for any other user it is the user itself. Not a stored column on User — resolved from the Consent at read time. Distinct from `user_id`, which is always the authenticated caller **Props:** Configuration properties file 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 fd125ef95e..b1b7eba05f 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -39,15 +39,17 @@ case class CallContext( // 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 / accountableUserId, where it takes precedence over consenter. - onBehalfOfUser: Box[User] = Empty, + // A SOURCE, not the resolved value: read via onBehalfOfUser / onBehalfOfUserId, where it + // takes precedence over consenter. + consentCreator: 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. + // In practice consentCreator and consenter are never both set: each consent standard + // populates the one whose source is authoritative for it. Kept as two fields on purpose + // (decided 2026-09-02): explicit about which standard bound the human. consenter: Box[User] = Empty, consumer: Box[Consumer] = Empty, ipAddress: String = "", @@ -112,10 +114,13 @@ case class CallContext( * 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 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 accountableUserId). + * Stored data (metric rows included) always carries the authenticated user; the on-behalf-of user + * is resolved at read time via the consent table (see onBehalfOfUserId). + * + * The resolved on-behalf-of user: consentCreator (OBP-native consents) or consenter (BG/UK), + * falling back to the authenticated user when no consent is in play. See ON_BEHALF_OF_USER_ID_PLAN.md. */ - def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user) + def onBehalfOfUser: Box[User] = consentCreator.or(consenter).or(user) //This is only used to connect the back adapter. not useful for sandbox mode. def toOutboundAdapterCallContext: OutboundAdapterCallContext= { @@ -123,7 +128,7 @@ case class CallContext( user <- this.user //If there is no user, then will go to `.openOr` method, to return anonymousAccess box. // The adapter is told which human is asking. A shadow user has no name and no customer links, // so sending it would make every consent-borne request look like a different, unknown caller. - psu <- this.humanUser + psu <- this.onBehalfOfUser username <- tryo(Some(psu.name)) currentResourceUserId <- tryo(Some(psu.userId)) consumerId = this.consumer.map(_.consumerId.get).openOr("") // if none, just return "" @@ -178,11 +183,11 @@ case class CallContext( CallContextLight( gatewayLoginRequestPayload = this.gatewayLoginRequestPayload, gatewayLoginResponseHeader = this.gatewayLoginResponseHeader, - // 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.accountableUserId. + // Like for like with CallContext: userId/userName are the AUTHENTICATED user + // (CallContext.user), never the resolved on-behalf-of user. Under a consent that is the + // consent user (a per-consent UUID with an empty name) — the on-behalf-of user is not + // stored here but resolved at read time via the consent table + // (consentReferenceId below -> consent.userId), see CallContext.onBehalfOfUserId. userId = this.user.map(_.userId).toOption, userName = this.user.map(_.name).toOption, consumerId = this.consumer.map(_.consumerId.get).toOption, @@ -217,32 +222,27 @@ case class CallContext( def userId: String = user.map(_.userId).openOrThrowException(AuthenticatedUserIsRequired) /** - * The ACCOUNTABLE identity this request is really about — the user_id that durable + * The ON-BEHALF-OF user id this request is really about — the user_id that durable * state (creator role grants, account holders, entitlement requests) and attribution - * (metrics families, "my" queries) bind to. "Accountable" deliberately hints at a - * legal person: today resolution always ends at the human who granted the consent, - * but the contract is accountability, not species — if durable, sponsored agent - * identities are ever admitted as principals in their own right, resolution may stop - * at such an agent without this name becoming a lie (unlike the previous name, - * effectiveHumanUserId). + * (metrics families, "my" queries) bind to. Vocabulary and design: ON_BEHALF_OF_USER_ID_PLAN.md. * - * The authenticated `user` may be the accountable party themselves, or a consent user - * minted by a Consent they granted (e.g. Opey / MCP acting under a consent) — consent - * users are ephemeral and must never hold durable state (see addEntitlement's guard). + * The authenticated `user` may be an original user acting for themselves, or a consent + * user minted by a Consent (e.g. Opey / MCP acting under a consent) — consent users are + * ephemeral and must never hold durable state (see addEntitlement's guard). * Resolution order: - * 1. `onBehalfOfUser` or `consenter`, when a middleware populated them (free); - * 2. otherwise resolve via the delegation registry: the caller's ResourceUser row's + * 1. `consentCreator` or `consenter`, when a middleware populated them (free); + * 2. otherwise resolve via the consent chain: the caller's ResourceUser row's * CreatedByConsentId names the Consent that minted it, and that Consent's userId - * names the granting human; - * 3. otherwise the caller IS the accountable party. + * names the on-behalf-of user; + * 3. otherwise the caller IS the on-behalf-of user. * * IMPORTANT: this reads only the authenticated user and server-written columns * (ResourceUser.CreatedByConsentId, MappedConsent.mUserId). It deliberately takes no * parameters so nothing caller-asserted (body/header/query values) can ever influence * the resolution — identity-sensitive queries (e.g. /my/banks) depend on that. */ - def accountableUserId: String = { - val delegatedHumanUserId = onBehalfOfUser.or(consenter).map(_.userId).filter(_.nonEmpty) + def onBehalfOfUserId: String = { + val delegatedHumanUserId = consentCreator.or(consenter).map(_.userId).filter(_.nonEmpty) delegatedHumanUserId.openOr { val authenticatedUserId = user.map(_.userId).openOr("") val grantingHumanUserId = for { diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 80df085beb..62674a7310 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -570,8 +570,8 @@ object Consent extends MdcLoggable { val temp = callContext // updated context if createdByUserId is present val ccWithOnBehalf = if (consent.createdByUserId.nonEmpty) { - val onBehalfOfUser = Users.users.vend.getUserByUserId(consent.createdByUserId) - temp.copy(onBehalfOfUser = onBehalfOfUser.toOption) + val consentCreator = Users.users.vend.getUserByUserId(consent.createdByUserId) + temp.copy(consentCreator = consentCreator.toOption) } else { temp } @@ -580,12 +580,12 @@ object Consent extends MdcLoggable { case Full(mc) => ccWithOnBehalf.copy(consentReferenceId = Some(mc.consentReferenceId)) case _ => ccWithOnBehalf } - if (cc.onBehalfOfUser.nonEmpty && + if (cc.consentCreator.nonEmpty && APIUtil.getPropsAsBoolValue(nameOfProperty = "experimental_become_user_that_created_consent", defaultValue = false)) { logger.warn("WARNING: experimental_become_user_that_created_consent is DEPRECATED and will be removed soon. Please unset this property.") logger.info("experimental_become_user_that_created_consent = true") - logger.info(s"${cc.onBehalfOfUser.map(_.userId).getOrElse("")} is logged on instead of Consent user") - Future(cc.onBehalfOfUser, Some(cc)) // Just propagate on behalf of user back + logger.info(s"${cc.consentCreator.map(_.userId).getOrElse("")} is logged on instead of Consent user") + Future(cc.consentCreator, Some(cc)) // Just propagate the consent creator back } else { logger.info("experimental_become_user_that_created_consent = false") logger.info(s"Getting Consent user (consent.sub: ${consent.sub}, consent.iss: ${consent.iss})") @@ -908,7 +908,7 @@ object Consent extends MdcLoggable { } yield { (principal, callContext.copy( // The PSU stays reachable for everything that needs a human: the CBS adapter, metric - // attribution, and CallContext.accountableUserId. + // attribution, and CallContext.onBehalfOfUserId. consenter = Full(psu), ukConsentId = Some(storedConsent.consentId), consentReferenceId = Some(storedConsent.consentReferenceId) @@ -1938,10 +1938,10 @@ object Consent extends MdcLoggable { * OBP-native answers to no external standard, so the contract is OBP's own API surface, and that * surface is explicit about the subject: this endpoint is /user/current/..., while its sibling * /consumer/current/consents/CONSENT_ID is the Consumer-scoped read. Two endpoints, two subjects. - * So the comparison here is against the human the request is on behalf of -- CallContext.humanUser, - * not CallContext.userId, which returns the authenticated principal and under consent - * authentication is the per-consent shadow user rather than the PSU. checkUKConsent already - * resolves the human this way for the same comparison. + * So the comparison here is against the on-behalf-of user -- CallContext.onBehalfOfUser, + * not CallContext.userId, which returns the authenticated user and under consent + * authentication is the consent user rather than the PSU. checkUKConsent already + * resolves the on-behalf-of user this way for the same comparison. * * A consent with no PSU yet stays readable, and that is deliberate rather than an oversight * inherited from the previous guard. This endpoint is where the PSU inspects a consent before diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala index 278565b0a9..1098344b78 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfActivityDashboardIndexes.scala @@ -88,7 +88,7 @@ object MigrationOfActivityDashboardIndexes { * Index on resourceuser.createdbyconsentid. * * The delegation registry: consent-agent fan-down (/my/metrics, /my/banks) and - * CallContext.accountableUserId look up agent users by the consent that minted them. + * CallContext.onBehalfOfUserId look up agent users by the consent that minted them. * Unindexed this is a full scan of resourceuser on every such request, which matters on * consent-heavy instances where every consent mints a user row. */ @@ -130,7 +130,7 @@ object MigrationOfActivityDashboardIndexes { s"""Added index on resourceuser.createdbyconsentid |Executed SQL: |$executedSql - |Serves the consent-agent delegation fan-down (/my/metrics, /my/banks, accountableUserId). + |Serves the consent-agent delegation fan-down (/my/metrics, /my/banks, onBehalfOfUserId). |""".stripMargin isSuccessful = true saveLog(name, commitId, isSuccessful, startDate, endDate, comment) diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala index c93256e6b9..5f20b5bcc7 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -848,7 +848,7 @@ object Http4s200 { loggedInUserId = user.userId // Implicit owner resolves to the HUMAN: under a Consent the caller is the // per-consent shadow, and an account held by it strands when the consent dies. - userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.accountableUserId + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.onBehalfOfUserId (postedOrLoggedInUser, cc2) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) // Explicit target: fail loud rather than redirect (see the entitlement endpoints). _ <- code.util.Helper.booleanToFuture( diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala index 25f50b29a1..1b2d0d0d68 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala @@ -468,7 +468,7 @@ object Http4s220 { ) // Creator grants target the HUMAN (see v6.0.0 createBank): under a Consent the // authenticated user is a per-consent shadow, and roles granted to it are stranded. - humanUserId = cc.accountableUserId + humanUserId = cc.onBehalfOfUserId entitlements <- Future { unboxFullOrFail( code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(humanUserId), diff --git a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala index 4bbe8c6dc7..29d063e1a5 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala @@ -1659,7 +1659,7 @@ object Http4s300 { // A request for power is a request BY the human: under a Consent the caller is a // per-consent shadow, and a request filed for it would have an admin granting to // an identity that dies with the consent (the grant endpoint now rejects that). - requesterUserId = cc.accountableUserId + requesterUserId = cc.onBehalfOfUserId _ <- code.util.Helper.booleanToFuture(EntitlementRequestAlreadyExists, cc = Some(cc)) { EntitlementRequest.entitlementRequest.vend.getEntitlementRequest(body.bank_id, requesterUserId, body.role_name).isEmpty } diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index 2e0aa3a717..cdff6f95fd 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -4323,7 +4323,7 @@ object Http4s310 { loggedInUserId = user.userId // Implicit owner resolves to the HUMAN: under a Consent the caller is the // per-consent shadow, and an account held by it strands when the consent dies. - userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.accountableUserId + userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.onBehalfOfUserId _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountIdStr) } _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bankIdStr) } (accountOwner, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 51840cd239..4c94d61999 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -10181,7 +10181,7 @@ object Http4s400 { // per-consent shadow, and an account held by it strands when the consent dies. userIdAccountOwner = if (createAccountJson.user_id.nonEmpty) createAccountJson.user_id - else cc.accountableUserId + else cc.onBehalfOfUserId (postedOrLoggedInUser, callContext) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) // Explicit target: fail loud rather than redirect (see the entitlement endpoints). _ <- code.util.Helper.booleanToFuture( @@ -10257,7 +10257,7 @@ object Http4s400 { // per-consent shadow, and an account held by it strands when the consent dies. userIdAccountOwner = if (createAccountJson.user_id.nonEmpty) createAccountJson.user_id - else cc.accountableUserId + else cc.onBehalfOfUserId (postedOrLoggedInUser, callContext) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) // Explicit target: fail loud rather than redirect (see the entitlement endpoints). _ <- code.util.Helper.booleanToFuture( diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 9511599473..788745fb6d 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -466,7 +466,7 @@ object Http4s500 { ) // Creator grants target the HUMAN (see v6.0.0 createBank): under a Consent the // authenticated user is a per-consent shadow, and roles granted to it are stranded. - humanUserId = cc.accountableUserId + humanUserId = cc.onBehalfOfUserId entitlements <- NewStyle.function.getEntitlementsByUserId(humanUserId, Some(cc)) entitlementsByBank = entitlements.filter(_.bankId == postJson.id.getOrElse("")) _ <- entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString()) match { @@ -590,7 +590,7 @@ object Http4s500 { loggedInUserId = user.userId // Implicit owner resolves to the HUMAN: under a Consent the caller is the // per-consent shadow, and an account held by it strands when the consent dies. - userIdAccountOwner = createAccountJson.user_id.getOrElse(cc.accountableUserId) + userIdAccountOwner = createAccountJson.user_id.getOrElse(cc.onBehalfOfUserId) _ <- Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountId.value) } _ <- Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(accountId.value) } (postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index c972fdf70c..b4e9d333b9 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4713,11 +4713,11 @@ object Http4s510 { for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) - // cc.humanUser, not cc.userId: under consent authentication the principal is the - // per-consent shadow user, so comparing it against the consent's PSU never matched and + // cc.onBehalfOfUser, not cc.userId: under consent authentication the authenticated user + // is the consent user, so comparing it against the consent's PSU never matched and // the PSU got a 404 for their own consent. See Consent.checkObpConsentUserAccess for // why an unbound consent stays readable. - _ <- Consent.checkObpConsentUserAccess(consent.userId, cc.humanUser.toOption.map(_.userId)) match { + _ <- Consent.checkObpConsentUserAccess(consent.userId, cc.onBehalfOfUser.toOption.map(_.userId)) match { case Some(reason) => Helper.booleanToFuture(failMsg = reason, failCode = 404, cc = Some(cc))(false) case None => Future.successful(true) } 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 a4bb0fe6b6..88d47a4e8d 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 @@ -175,7 +175,7 @@ object Http4s600 { // Route: GET /obp/v6.0.0/users/current // Auth-only. Returns the logged-in user enriched with entitlements, // virtual roles (super_admin / oidc_operator), permissions, and the - // optional on-behalf-of user when impersonation headers are set. + // optional on-behalf-of user when the request runs under a consent. lazy val getCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / "current" => EndpointHelpers.withUser(req) { (user, cc) => @@ -203,9 +203,12 @@ object Http4s600 { } } val currentUser = UserV600(user, entitlements ::: virtualEntitlements, permissions) + // The delegated on-behalf-of user only (consentCreator for OBP-native consents, + // consenter for BG/UK) — NOT cc.onBehalfOfUser, whose .or(user) fallback would show a + // plain user as their own on-behalf-of. Null unless a consent is in play. val onBehalfOfUser = - if (cc.onBehalfOfUser.isDefined) { - val u = cc.onBehalfOfUser.toOption.get + if (cc.consentCreator.or(cc.consenter).isDefined) { + val u = cc.consentCreator.or(cc.consenter).toOption.get val ents = Entitlement.entitlement.vend.getEntitlementsByUserId(u.userId) .headOption.toList.flatten val perms = Views.views.vend.getPermissionForUser(u).toOption @@ -493,7 +496,7 @@ object Http4s600 { // Creator grants target the HUMAN (see createBank): a per-consent shadow principal // must not end up owning the entity's admin roles. crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.accountableUserId, role.toString(), + Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.onBehalfOfUserId, role.toString(), grantedByUserId = Some(cc.userId))) JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head } @@ -874,7 +877,7 @@ object Http4s600 { // Consent the principal is a per-consent shadow user, and a role granted to it // is stranded when the consent dies (and invisible to the human's next consent). // grantedByUserId stays the principal — the audit trail records who acted. - humanUserId = cc.accountableUserId + humanUserId = cc.onBehalfOfUserId entitlements <- NewStyle.function.getEntitlementsByUserId(humanUserId, Some(cc)) entitlementsByBank = entitlements.filter(_.bankId == postJson.bank_id) _ = if (!entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString)) 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 c27c550700..d2ce739adb 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 @@ -287,12 +287,12 @@ object Http4s700 { // Response shapes reuse the v6 bank JSON (BankJson600 / BanksJsonV600). // ─── Delegation fan-down for /my/banks ─────────────────────────────────── - // Resolving UP (agent caller → the granting human) is cc.accountableUserId. + // Resolving UP (agent caller → the granting human) is cc.onBehalfOfUserId. // This is the fan DOWN: the human plus every agent user minted from any Consent the // human granted — i.e. all user ids whose creations belong to that human. Match the // result against CreatedByUserId. Reads only server-written columns // (MappedConsent.mUserId, ResourceUser.CreatedByConsentId); the input must be an - // already-resolved human id (cc.accountableUserId), never a raw caller value. + // already-resolved human id (cc.onBehalfOfUserId), never a raw caller value. private def humanAndAgentUserIds(humanUserId: String): List[String] = { val consentIds = Consents.consentProvider.vend.getConsentsByUser(humanUserId) @@ -336,7 +336,7 @@ object Http4s700 { // Quota binds to the human: banks created by the human directly or by any // of their consent-agents count toward the same limit — otherwise every // new consent would arrive with a fresh quota. - val creatorUserIds = humanAndAgentUserIds(cc.accountableUserId) + val creatorUserIds = humanAndAgentUserIds(cc.onBehalfOfUserId) MappedBank.count(ByList(MappedBank.CreatedByUserId, creatorUserIds)) } _ <- Helper.booleanToFuture(SelfServiceBankLimitReached, failCode = 403, cc = Some(cc)) { @@ -359,7 +359,7 @@ object Http4s700 { // Creator grant targets the HUMAN (see v6.0.0 createBank): under a Consent the // authenticated user is a per-consent shadow, and roles granted to it are stranded. _ <- Future(Entitlement.entitlement.vend.addEntitlement( - generatedName.bankId, cc.accountableUserId, canCreateEntitlementAtOneBank.toString(), + generatedName.bankId, cc.onBehalfOfUserId, canCreateEntitlementAtOneBank.toString(), grantedByUserId = Some(cc.userId))) } yield JSONFactory600.createBankJSON600(bank) } @@ -417,7 +417,7 @@ object Http4s700 { EndpointHelpers.withUser(req) { (user, cc) => for { banksCreatedByUser <- Future { - val creatorUserIds = humanAndAgentUserIds(cc.accountableUserId) + val creatorUserIds = humanAndAgentUserIds(cc.onBehalfOfUserId) MappedBank.findAll(ByList(MappedBank.CreatedByUserId, creatorUserIds)) } } yield JSONFactory600.createBanksJsonV600(banksCreatedByUser) @@ -946,9 +946,12 @@ object Http4s700 { } } val currentUser = UserV600(user, entitlements ::: virtualEntitlements, permissions) + // The delegated on-behalf-of user only (consentCreator for OBP-native consents, + // consenter for BG/UK) — NOT cc.onBehalfOfUser, whose .or(user) fallback would show a + // plain user as their own on-behalf-of. Null unless a consent is in play. val onBehalfOfUser = - if (cc.onBehalfOfUser.isDefined) { - val u = cc.onBehalfOfUser.toOption.get + if (cc.consentCreator.or(cc.consenter).isDefined) { + val u = cc.consentCreator.or(cc.consenter).toOption.get val ents = Entitlement.entitlement.vend.getEntitlementsByUserId(u.userId) .headOption.toList.flatten val perms = Views.views.vend.getPermissionForUser(u).toOption @@ -1148,7 +1151,7 @@ object Http4s700 { // human, then fan down — both via server-written columns only. (metrics, _) <- APIMetrics.getMetricsFromHttpParams( httpParams, cc.callContext, - lockedUserIds = Some(humanAndAgentUserIds(cc.accountableUserId))) + lockedUserIds = Some(humanAndAgentUserIds(cc.onBehalfOfUserId))) } yield JSONFactory600.createMetricsJsonV600(metrics) } } @@ -4210,7 +4213,7 @@ object Http4s700 { // CanCreateAccount is enforced by ResourceDocMiddleware from the doc. // The implicit owner is the HUMAN: under a Consent the caller (user.userId) is the // per-consent shadow, and an account held by it strands when the consent dies. - ownerId = body.user_id.filter(_.trim.nonEmpty).getOrElse(cc.accountableUserId) + ownerId = body.user_id.filter(_.trim.nonEmpty).getOrElse(cc.onBehalfOfUserId) (owner, _) <- NewStyle.function.findByUserId(ownerId, Some(cc)) // Explicit target: fail loud rather than redirect (see the entitlement endpoints). _ <- Helper.booleanToFuture( diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index fc5febee1e..0fe35c0ae2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1535,9 +1535,9 @@ object LocalMappedConnectorInternal extends MdcLoggable { ) // Holder is the HUMAN: under a Consent cc.user is the per-consent shadow, and a // holding account held by it would strand when the consent dies. For non-consent - // callers accountableUserId is the caller, so this is a no-op for them. + // callers onBehalfOfUserId is the caller, so this is a no-op for them. holdingAccountHolder = cc.flatMap(c => - code.users.Users.users.vend.getUserByUserId(c.accountableUserId).toOption + code.users.Users.users.vend.getUserByUserId(c.onBehalfOfUserId).toOption ).getOrElse(cc.get.user.head) _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, holdingAccountHolder, callContext) // create attribute on holding account to link to releaser account diff --git a/obp-api/src/main/scala/code/metrics/APIMetrics.scala b/obp-api/src/main/scala/code/metrics/APIMetrics.scala index c3dfa8e784..ea87f9cb76 100644 --- a/obp-api/src/main/scala/code/metrics/APIMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/APIMetrics.scala @@ -215,7 +215,7 @@ case class AggregateMetrics( minResponseTime: 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.accountableUserId. + // (on-behalf-of) user via the consent table, mirroring CallContext.onBehalfOfUserId. distinctUserCount: Int, distinctConsumerCount: Int, // Calls that arrived under a consent (metric.consent_reference_id not null), and how many diff --git a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala index e6ad043619..acb8fb654a 100644 --- a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala @@ -435,7 +435,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ // 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.accountableUserId. (Rows + // to the granting human at read time, mirroring CallContext.onBehalfOfUserId. (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. 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 bb305ec7d2..86db0c961a 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala @@ -95,12 +95,6 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa object LastUsedLocale extends MappedString(this, 10) { override def defaultValue = null } - object IsNaturalPerson extends MappedBoolean(this) { - override def defaultValue = true - } - 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 @@ -139,8 +133,6 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa override def isDeleted: Option[Boolean] = if(IsDeleted.jdbcFriendly(IsDeleted.calcFieldName) == null) None else Some(IsDeleted.get) // null --> None override def lastMarketingAgreementSignedDate: Option[Date] = if(IsDeleted.jdbcFriendly(LastMarketingAgreementSignedDate.calcFieldName) == null) None else Some(LastMarketingAgreementSignedDate.get) // null --> None 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) @@ -149,7 +141,7 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa object ResourceUser extends ResourceUser with LongKeyedMetaMapper[ResourceUser]{ // userId_ is deliberately NOT declared here: MigrationOfUserIdIndexes creates a stronger // UNIQUE index on it (resourceuser_userid_unique). CreatedByConsentId is the delegation - // registry — consent-agent fan-down (/my/metrics, /my/banks) and accountableUserId join + // registry — consent-agent fan-down (/my/metrics, /my/banks) and onBehalfOfUserId join // through it; nothing else indexes it, which matters on consent-heavy instances where every // consent mints a user row. override def dbIndexes = UniqueIndex(provider_, providerId) :: Index(CreatedByConsentId) :: super.dbIndexes diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index c0b83269c0..f64adb0f15 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -169,7 +169,7 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with .mApiVersion(apiVersion.getOrElse(null)) .mApiStandard(apiStandard.getOrElse(null)) .mUserId(callContext.flatMap(_.user.map(_.userId)).getOrElse(null)) - .mOnBehalfOfUserId(callContext.flatMap(cc => cc.onBehalfOfUser.or(cc.consenter).map(_.userId)).getOrElse(null)) + .mOnBehalfOfUserId(callContext.flatMap(cc => cc.consentCreator.or(cc.consenter).map(_.userId)).getOrElse(null)) .mConsumerId(callContext.flatMap(_.consumer.map(_.consumerId.get)).getOrElse(null)) // Explicit originator fields (FATF Rec 16, OPEN_CORRIDOR_PROMISE type only — null otherwise). diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index 1a5cd589f3..b720b10120 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -318,9 +318,7 @@ object LiftUsers extends Users with MdcLoggable{ userId: Option[String], createdByUserInvitationId: Option[String], company: Option[String], - lastMarketingAgreementSignedDate: Option[Date], - isNaturalPerson: Option[Boolean] = Some(true), - principalUserId: Option[String] = None): Box[ResourceUser] = { + lastMarketingAgreementSignedDate: Option[Date]): Box[ResourceUser] = { val ru = ResourceUser.create ru.provider_(provider) providerId match { @@ -355,14 +353,6 @@ object LiftUsers extends Users with MdcLoggable{ case Some(v) => ru.LastMarketingAgreementSignedDate(v) case None => } - isNaturalPerson match { - case Some(v) => ru.IsNaturalPerson(v) - case None => - } - principalUserId match { - case Some(v) => ru.PrincipalUserId(v) - case None => - } Full(ru.saveMe()) } diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index 95e08d4e2c..1c6427b968 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -81,9 +81,7 @@ trait Users { userId: Option[String], createdByUserInvitationId: Option[String], company: Option[String], - lastMarketingAgreementSignedDate: Option[Date], - isNaturalPerson: Option[Boolean] = Some(true), - principalUserId: Option[String] = None) : Box[ResourceUser] + lastMarketingAgreementSignedDate: Option[Date]) : Box[ResourceUser] def createUnsavedResourceUser(provider: String, providerId: Option[String], name: Option[String], email: Option[String], userId: Option[String]) : Box[ResourceUser] diff --git a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala index 571480a9a7..8fb670967e 100644 --- a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala +++ b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala @@ -14,7 +14,7 @@ import org.scalatest.Tag * - LiftUsers.createResourceUser field assignments — pins the 2021 copy-paste bug where * the createdByUserInvitationId None branch wiped CreatedByConsentId (the consent → * agent linkage every delegation query joins through). - * - CallContext.accountableUserId — resolve-up from the authenticated caller (human + * - CallContext.onBehalfOfUserId — resolve-up from the authenticated caller (human * or consent-minted agent) to the human the request is really about, including the * branch an HTTP test cannot reach (the agent as the caller). */ @@ -71,23 +71,23 @@ class AgentDelegationTest extends ServerSetup { } } - feature("CallContext.accountableUserId resolves the caller to the human the request is about") { + feature("CallContext.onBehalfOfUserId resolves the caller to the human the request is about") { scenario("a plain human resolves to themselves", AgentDelegationTag) { val human = createUser() - CallContext(user = Full(human)).accountableUserId shouldBe human.userId + CallContext(user = Full(human)).onBehalfOfUserId shouldBe human.userId } scenario("a consent-minted agent resolves to the granting human", AgentDelegationTag) { val human = createUser() val consent = MappedConsent.create.mUserId(human.userId).saveMe() val agent = createUser(createdByConsentId = Some(consent.consentId)) - CallContext(user = Full(agent)).accountableUserId shouldBe human.userId + CallContext(user = Full(agent)).onBehalfOfUserId shouldBe human.userId } scenario("an agent with a dangling consent id falls back to itself (fails closed)", AgentDelegationTag) { val agent = createUser(createdByConsentId = Some(generateUUID())) - CallContext(user = Full(agent)).accountableUserId shouldBe agent.userId + CallContext(user = Full(agent)).onBehalfOfUserId shouldBe agent.userId } scenario("a populated consenter box wins over the DB chain", AgentDelegationTag) { @@ -96,18 +96,18 @@ class AgentDelegationTest extends ServerSetup { val agent = createUser(createdByConsentId = Some(consent.consentId)) val consenterHuman = createUser() CallContext(user = Full(agent), consenter = Full(consenterHuman)) - .accountableUserId shouldBe consenterHuman.userId + .onBehalfOfUserId shouldBe consenterHuman.userId } - scenario("onBehalfOfUser wins over consenter", AgentDelegationTag) { + scenario("consentCreator wins over consenter", AgentDelegationTag) { val agent = createUser() val consenterHuman = createUser() val explicitHuman = createUser() CallContext( user = Full(agent), consenter = Full(consenterHuman), - onBehalfOfUser = Full(explicitHuman) - ).accountableUserId shouldBe explicitHuman.userId + consentCreator = Full(explicitHuman) + ).onBehalfOfUserId shouldBe explicitHuman.userId } } } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala index 47386f389d..06a7650366 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala @@ -48,7 +48,7 @@ import org.scalatest.Tag * GET /obp/v5.1.0/user/current/consents/CONSENT_ID compared the consent's PSU against * CallContext.userId -- the authenticated principal. Under Consent-Id / Consent-JWT authentication * that principal is the per-consent shadow user, never the human, so the comparison could not match - * and the PSU was told their own consent did not exist. The subject is now CallContext.humanUser, + * and the PSU was told their own consent did not exist. The subject is now CallContext.onBehalfOfUser, * the accessor the codebase already keeps for exactly this distinction. * * The rule, and why a consent with no PSU yet stays readable by anyone, is in diff --git a/obp-api/src/test/scala/code/util/ApiSessionTest.scala b/obp-api/src/test/scala/code/util/ApiSessionTest.scala index aaca33dbb6..fc007f79bf 100644 --- a/obp-api/src/test/scala/code/util/ApiSessionTest.scala +++ b/obp-api/src/test/scala/code/util/ApiSessionTest.scala @@ -130,11 +130,11 @@ class ApiSessionTest extends FeatureSpec with Matchers with GivenWhenThen with M } // 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") + // userId/userName come from the AUTHENTICATED user (CallContext.user), never from + // the resolved on-behalf-of user. Under a consent the authenticated user is the consent + // user; the on-behalf-of user stays on the context as consenter/consentCreator and is + // resolved at read time via the consent table, never baked into stored rows. + scenario("userId and userName carry the AUTHENTICATED user, even when consenter and consentCreator are set") { val principal = ResourceUser.create.userId_("principal-user-id").name_("principal-name") val human = ResourceUser.create.userId_("human-user-id").name_("human-name") @@ -142,7 +142,7 @@ class ApiSessionTest extends FeatureSpec with Matchers with GivenWhenThen with M val light = CallContext( user = Full(principal), consenter = Full(human), - onBehalfOfUser = Full(human), + consentCreator = Full(human), directLoginParams = Map("token" -> "dl-token") ).toLight 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 e2f35935ed..56a0944fa4 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 @@ -71,8 +71,6 @@ trait User { def isDeleted: Option[Boolean] def lastMarketingAgreementSignedDate: Option[Date] 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 From 715989c11f74bea35db0534304c188e8be065775 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 2 Sep 2026 12:42:58 +0200 Subject: [PATCH 3/5] On behalf of Phase 1 --- ON_BEHALF_OF_USER_ID_PLAN.md | 108 +++++++-- .../main/scala/code/api/util/ApiSession.scala | 15 +- .../code/entitlement/MappedEntitlements.scala | 42 ++-- .../MappedTransactionRequestProvider.scala | 15 +- .../src/main/scala/code/users/LiftUsers.scala | 80 ++++++- .../main/scala/code/users/UserReference.scala | 226 ++++++++++++++++++ obp-api/src/main/scala/code/users/Users.scala | 24 ++ .../code/api/util/AgentDelegationTest.scala | 127 +++++++++- 8 files changed, 574 insertions(+), 63 deletions(-) create mode 100644 obp-api/src/main/scala/code/users/UserReference.scala diff --git a/ON_BEHALF_OF_USER_ID_PLAN.md b/ON_BEHALF_OF_USER_ID_PLAN.md index 7331707fbc..fcddc0c63f 100644 --- a/ON_BEHALF_OF_USER_ID_PLAN.md +++ b/ON_BEHALF_OF_USER_ID_PLAN.md @@ -1,8 +1,10 @@ # On-behalf-of user id — making ownership-by-the-human automatic Written 2026-09-02 evening, for pickup 2026-09-03. This is the only document: no separate -checklist. Track progress here by marking items done in place. **Status: Phase 0 done 2026-09-02 (uncommitted): clean build green, `AgentDelegationTest` / -`ApiSessionTest` / `ConsentOwnershipTests` all pass (21 tests). Phase 1 next.** Background and the reasoning are on the Portal page `/developers/opey-permissions` +checklist. Track progress here by marking items done in place. **Status: Phases 0 and 1 done 2026-09-02 (uncommitted). Clean build green; `AgentDelegationTest` +(22), `ApiSessionTest`, `ConsentOwnershipTests`, `ConsentObpTest`, `ConsentTest`, `EntitlementTests` +all pass. `AbacRuleTests` fails locally for an unrelated props reason (see Phase 1 note 2). Phase 2 +next; manual litmus tests under Phase 1.** Background and the reasoning are on the Portal page `/developers/opey-permissions` (OBP-Frontend, uncommitted) and in `OBP-Frontend/CONSENT_ESCALATION_GAP.md`. Working rules: the user commits, the assistant never does. The provider is the mechanism; @@ -82,14 +84,18 @@ rename of an accessor, plus one documentation fix. No schema migration. | 8 ✅ | v6/v7 `/users/current` JSON field `on_behalf_of` reads the `consentCreator` field only (null for BG/UK consents) | reads `consentCreator.or(consenter)` — the delegated value, **not** the resolved `onBehalfOfUser`, whose `.or(user)` fallback would show a plain user as their own on-behalf-of | `Http4s600.scala:174-213`, `Http4s700.scala:949-956`; endpoint comment ("impersonation headers", stale; fixed). No resource-doc text mentions the field, nothing to change there | **optional, not needed by Phase 1.** Additive behaviour change: BG/UK consent callers get the consenter instead of null; everyone else unchanged (Decisions 8); release-note it | | 9 ✅ | — | **checked 2026-09-02: yes, always the same user.** | `MappedConsent.scala:205,232,279`; `ConsentUtil.scala:1359,1457,1647`; create endpoints `Http4s310:4451`, `Http4s500:1265`, `Http4s510:5025` | An OBP consent names its user twice: the row column `mUserId` and the JWT claim `createdByUserId`. OBP-native: all three create endpoints pass the logged-in `user` to both. BG/UK: both empty at creation, both set to the authorising user at authorisation (`updateConsentUser` + `updateUserIdOfBerlinGroupConsentJWT`). So `CallContext`'s two sources (`onBehalfOfUser` field from the claim, `consenter` from the column) always carry one value. **Decided: keep the two source fields separate anyway** (`consentCreator`, `consenter`) — explicit about where each came from; the resolved `onBehalfOfUser` (row 6) is the one to read. | -Not renamed: local `val humanUserId = cc.onBehalfOfUserId` in the createBank endpoints (`Http4s220:471`, `Http4s500:469`, `Http4s600:877`) and `Http4s700.humanAndAgentUserIds` — locals, Phase 3 touches those endpoints anyway; `consenter` (a source, name is accurate), `User.isConsentUser` / `Constant.consent_user` +Not renamed: local `val humanUserId = cc.onBehalfOfUserId` in the createBank endpoints (`Http4s220:471`, `Http4s500:469`, `Http4s600:877`) and `Http4s700.humanAndAgentUserIds` — locals, Phase 2 touches those endpoints anyway; `consenter` (a source, name is accurate), `User.isConsentUser` / `Constant.consent_user` (the kind of user), `mOnBehalfOfUserId` and `on_behalf_of_user_id` (already right). The ABAC rule engine's `onBehalfOfUser` parameter (`AbacRuleEngine.scala:33,164`) is a separate rule-input slot, always `None` today; leave it, it already uses the right word. Done when (both satisfied 2026-09-02): `grep -rn "PrincipalUserId\|principalUserId\|IsNaturalPerson\|isNaturalPerson\|humanUser\b\|accountableUserId" obp-api/src obp-commons/src` is empty and `AgentDelegationTest`, `ApiSessionTest`, `ConsentOwnershipTests` pass. -## Phase 1 — one resolver, policy-aware entry point (decided: lives in `Users`) +## Phase 1 — one resolver + the complete policy file (decided: lives in `Users`) — **done 2026-09-02, tests green** + +Phase 1 delivers the design whole: the resolver, the policy-aware entry point, **and every row of +the policy file**. Nothing calls the rows until Phase 2, but the file is declarative and one line +per row, so there is no reason to ship it in pieces. Resolver home: trait `code.users.Users`, impl `LiftUsers`. Chosen over a separate object because `LiftUsers` is the only writer of `CreatedByConsentId`, it @@ -138,7 +144,7 @@ def attributionOf(userId: String, ref: UserReference): Box[Attribution] def attributedUserId(userId: String, ref: UserReference): Box[String] = attributionOf(userId, ref).map(_.userIdToStore) ``` -`UserReference` is the Phase-2 policy file as code (main tree, see Phase 2). The `ref` argument is +`UserReference` is the policy file as code (main tree, see "The policy file" below). The `ref` argument is chosen by provider code, never from the request, so the "no caller-asserted input" property of `onBehalfOfUserIdOf` still holds. `consentId` is derived inside the resolver, never passed in, for the same reason. @@ -149,21 +155,26 @@ Implementation notes: inline today (`ResourceUser.find(By(userId_)) → CreatedByConsentId → getConsentByConsentId → consent.userId`). Both then delegate to it; CallContext keeps its `consentCreator.or(consenter)` precedence in front. -2. Cache (decided): `Caching.memoizeSyncWithImMemory` (Guava via scalacache, already used), - key `onBehalfOfUserIdOf:`, TTL 10 min. Memoise humans (answer = self) and bound - consent users. Do **not** memoise the "consent user whose consent names no human yet" branch, - or a BG consent bound a minute later stays pinned to the consent user for the TTL. +2. Cache (as built): a private Guava cache in `LiftUsers` (the `Caching` wrapper cannot skip + memoising selected answers), TTL from props `on_behalf_of_user_id.cache_ttl_seconds`, default + 600, `0` disables. Memoises original users (answer = self) and bound consent users. Note for + local test runs: `AbacRuleTests` (and the other dynamic-code suites) return 400 on rule creation + unless `allow_user_generated_scala_code=true` is in the test props, as CI sets it; that is + unrelated to this work. Does **not** + memoise "consent user whose consent names no human yet", dangling ids, or the invariant + failure, so a BG consent bound a minute later is seen at once. 3. Agents (decided, see Decisions 4): an agent is a consent user; there is no consent-less agent and no second column. The resolver is one hop (no chains) and asserts the target row `isOriginalUser`; if not, WARN and `Failure`. The `Reject` policy on consent creation by a consent user is what keeps chains from ever being written. 4. Every delegated attribution logs WARN with the `ref` name, `userId`, `onBehalfOfUserId`, `consentId`. A WARN firing in tests means a site chose the wrong reference or a policy is wrong. -5. **Check before coding**: for OBP-native consents, `CallContext` prefers the JWT's - `createdByUserId` (`consentCreator`) while the resolver follows `consent.userId`. Same person - when a human creates their own consent in the Portal; verify no creation path sets `mUserId` - to someone other than the creator (`MappedConsentProvider.scala:54,205,232,279`). If one does, - decide which wins and write it down here. +5. **Checked 2026-09-02 (Phase 0 row 9): the two sources always agree.** `CallContext` prefers + the JWT claim `createdByUserId` (`consentCreator`); the resolver follows the row column + `consent.userId`. Every OBP-native create endpoint (`Http4s310:4451`, `Http4s500:1265`, + `Http4s510:5025`) writes both from the same logged-in `user`; BG/UK set both to the authorising + user at authorisation. No path writes them differently, so precedence is a no-op today and the + resolver's answer equals the request-layer answer. Kept as two fields anyway (decided). Call sites after Phase 1: @@ -184,7 +195,23 @@ for { a <- Users.users.vend.attributionOf(userId, UserReference.TransactionReque tr.mUserId(a.userId).mOnBehalfOfUserId(a.onBehalfOfUserId) ``` -## Phase 2 — assign an attribution policy to every user-reference column (from a grep of Mapped classes) +### The policy file — an attribution policy for every user-reference column (from a grep of Mapped classes) + +**Written 2026-09-02: `obp-api/src/main/scala/code/users/UserReference.scala` is now the source of +truth — 72 references, 9 not-a-user-id exclusions.** The tables below were the draft; the file was +generated from an inventory of every model in `ToSchemify.models` and covers more than the tables. +Columns the draft missed, and the policy given (change in the file if wrong): + +| policy | added | +|---|---| +| `KeepUserId` | `AuthUser.user` (login row), `OpenIDConnectToken.AuthUserPrimaryKey`, `MappedUserRefreshes.mUserId`, `MetricArchive.userId`, `DynamicDataAccess.GrantedBy` (audit) | +| `UseOnBehalfOfUserId` | `MappedUserScope.mUserId`, `DirectDebit.UserId`, `DynamicData.UserId`, `DynamicDataAccess.UserId`, `MappedCounterpartyWhereTag.user`, `MappedTag.user`, `MappedWhereTag.user`, `MappedTransactionImage.user`, `MappedCustomerMessage.user`, `MappedKycDocument.user`, `MappedKycStatus.user`, `MappedSocialMedia.user`, `MappedKycCheck.user`, `SignatoryPanel.UserIds`, `ChatMessage.MentionedUserIds` | +| `Reject` | `Token.userForeignKey` (OAuth token issued to a consent user) | +| not a user id | `MappedBankAccount.holder`, `MappedTransaction.counterpartyAccountHolder`, `AccountAccessRequest.CheckerComment`, `MappedKycCheck.mStaffName`, `MappedMeeting.mStaffToken`, `MappedEntitlement.mCreatedByProcess`, `ResourceUser.userId_` / `CreatedByConsentId` / `CreatedByUserInvitationId` | + +`AccountAccessRequest` is three references (requestor, target, checker). Record-both tables are one +reference with two fields (`TransactionRequest`). Classes are named as fully-qualified strings, not +`classOf`, so the file imports nothing and cannot trigger Mapper initialisation. Rule: **the agent owns nothing durable.** Only the consent's own authorisation rows stay on the consent user. @@ -227,7 +254,7 @@ object UserReference { } ``` -Carrying `mapper` + `fields` on each value is what lets the Phase-5 frozen test tie every +Carrying `mapper` + `fields` on each value is what lets the Phase-4 frozen test tie every reflected Mapper column to exactly one reference (one column may have two references only when they differ by process, as `MappedEntitlement.mUserId` does). @@ -279,9 +306,44 @@ they differ by process, as `MappedEntitlement.mUserId` does). | 31 | `consent/MappedConsent.mUserId` (consent creating a consent) | nested delegation; 400 at the create endpoints | | 32 | `model/OAuth.createdByUserId` (tokens/consumers minted by a consent user) | credentials outlive the consent; 400 | -Phase-2 deliverable: `UserReference.scala` in the **main** tree, one case object per row of the tables above, `all` listing them. Not a database table, and not these markdown tables: the markdown is the working draft, the Scala file is what runs (via `Users.attributionOf`) and what `UserReferenceAttributionPolicyTest` (Phase 5) checks. - -## Phase 3 — provider guards (UseOnBehalfOfUserId) +### Phase 1 deliverables (all ✅ 2026-09-02) + +1. `obp-api/src/main/scala/code/users/UserReference.scala`: `AttributionPolicy`, `Attribution`, and `UserReference` with **one case object per row of the tables above (all 32)** and `all` listing them. Not a database table, and not these markdown tables: the markdown is the working draft, the Scala file is what runs (via `Users.attributionOf`) and what `UserReferenceAttributionPolicyTest` (Phase 4) checks. +2. `Users` trait: `onBehalfOfUserIdOf`, `actsForSelf`, `attributionOf`, `attributedUserId`. +3. `LiftUsers`: the implementation, with the cache rule and the `isOriginalUser` check. +4. `CallContext.onBehalfOfUserId` delegates to the resolver (precedence kept). +5. `MappedEntitlements.addEntitlement` via `attributedUserId` with `ConsentEntitlementUser` / `EntitlementUser`. +6. `MappedTransactionRequestProvider` via one `attributionOf(userId, TransactionRequest)` call, both columns. +7. `AgentDelegationTest` scenarios (Phase 4, item 1) green; grep for any other inline copy of the chain and point it at the resolver. + +## Manual tests after Phase 1 (litmus, against a running instance) + +Set-up once: a human H logged in (Portal / API Explorer), an OBP-native consent C granted by H +with roles that let it act (e.g. `CanCreateEntitlementAtOneBank`, `CanCreateAccount`), and the +consent JWT for C. Calls "as C" send `Consent-JWT: ` plus the consumer key; calls "as H" use +H's normal token. + +1. **Who am I / on whose behalf.** As C: `GET /obp/v6.0.0/users/current`. Expect `user_id` = C's + consent user, `on_behalf_of.user_id` = H. As H: `on_behalf_of` is null. (Row 8 of Phase 0.) +2. **Entitlement redirect.** As C: `POST /obp/v7.0.0/users//entitlements` + with a role C may grant. Expect 201 and the entitlement's `user_id` = H, not C. Then + `GET /obp/v6.0.0/users/current` as H shows the role. Log has one WARN from + `attribution EntitlementUser` naming C, H, and the consent id. +3. **Consent-engine exemption.** Create a new consent as H and use it once. The consent user's own + rows in `entitlement` (createdByProcess `consent_user`) are on the consent user, not on H. +4. **Payment attribution.** As C: create a transaction request (`SANDBOX_TAN` is enough) on one of + H's accounts. Expect the row in `transactionrequest`: `muserid` = C's consent user, + `monbehalfofuserid` = H. Then `GET .../transaction-requests` as H lists it. +5. **Reject.** As C: `POST /obp/v5.1.0/my/consents/IMPLICIT` (create a consent while being a + consent user). Until Phase 3 this still succeeds — it is the litmus that Phase 3 is needed. + After Phase 3: 400 `OBP-30107` naming `ConsentCreator`. +6. **BG late binding (if a BG sandbox is set up).** Create a BG consent via the TPP flow, call + `/users/current` with it before authorisation: `on_behalf_of` null. Authorise as H, call again + within a minute: `on_behalf_of.user_id` = H (proves the unbound answer was not cached). +7. **Cache.** Set `on_behalf_of_user_id.cache_ttl_seconds=0` in props, repeat 2: same result, and + the log shows the chain walked on every call. Restore the default. + +## Phase 2 — provider guards (UseOnBehalfOfUserId) Pattern, one line at the top of each create/link method, naming the column being written: @@ -293,7 +355,7 @@ for { Providers that return a plain value rather than a `Box` either grow a `Box` (preferred) or `openOr(userId)` with a comment. Both ways to be wrong — forgetting the call, or naming the wrong -reference — are caught by the Phase-5 sweep; the second is also visible in review. +reference — are caught by the Phase-4 sweep; the second is also visible in review. 1. Providers that take a `User` (AccountHolders): resolve to id, re-fetch the on-behalf-of `User` once (cached). 2. Keep endpoint-level `cc.onBehalfOfUserId` uses; they become redundant clarity, not the mechanism. @@ -301,18 +363,18 @@ reference — are caught by the Phase-5 sweep; the second is also visible in rev Order of attack (highest strand-risk first): AccountHolders → UserCustomerLink → AccountApplication → UserAuthContext → ApiCollection/UserAttribute → the rest mechanically. -## Phase 4 — explicit-target guards (endpoint 400s) +## Phase 3 — explicit-target guards (endpoint 400s) Doctrine (settled 2026-09-01): implicit self → redirect in provider; explicit `USER_ID` naming a consent user → 400 `InvalidUserId … names a consent user`. Already done: addEntitlement (v2.0/v7), addUserToGroup (v6), createAccount (v2.0/v3.1/v4.0/v5.0/v7), grantUserAccessToViewById (v5.1), account access requests (v6), account applications (v3.1). To sweep: createUserCustomerLink, API collections, user attributes, auth contexts, KYC/meeting staff ids, webhooks with explicit ids. `Reject` columns refuse in the provider (`attributionOf` returns Failure); endpoints map that to 400 and may keep an early explicit check for a nicer message, but the floor holds without them. -## Phase 5 — tests +## Phase 4 — tests 1. **`AgentDelegationTest`** — extend: `onBehalfOfUserIdOf` for original user / consent user / dangling consent (fails closed) / cache hit after consent later bound (BG case) / consent whose user is itself a consent user → Failure; `attributionOf` for each of the three policies. 2. **`UserReferenceAttributionPolicyTest`** (frozen-style, like `FrozenClassTest`): iterate `ToSchemify.models`, reflect Mapper fields whose name matches `(?i)userid|createdby|grantedby|holder`, assert every (class, field) is named by at least one `UserReference` in `UserReference.all`, and by more than one only where the references differ by process; assert every `UserReference` names real Mapper fields. New tables and renamed columns fail until sorted. 3. **`OnBehalfOfOwnershipSweepTest`**: mint a consent for a test human with generous roles; call every `UseOnBehalfOfUserId` create endpoint with the consent JWT; assert no row in any such table references the consent user's id, and at least one references the human. Also assert `Reject` endpoints return 400. 4. Existing `ConsentObpTest` / `ConsentTest` keep passing (35033 now only AnyBank). -## Phase 6 — follow-through +## Phase 5 — follow-through 1. Portal page `/developers/opey-permissions`: shrink "Attribution Is Not Yet Universal" to one line once the sweep test is green; use the vocabulary above there too. 2. Memory: write `on-behalf-of-user-id-plan` (none exists yet) pointing at this file, then mark built. 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 b1b7eba05f..0dfbab6586 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -242,17 +242,12 @@ case class CallContext( * the resolution — identity-sensitive queries (e.g. /my/banks) depend on that. */ def onBehalfOfUserId: String = { - val delegatedHumanUserId = consentCreator.or(consenter).map(_.userId).filter(_.nonEmpty) - delegatedHumanUserId.openOr { + val delegatedUserId = consentCreator.or(consenter).map(_.userId).filter(_.nonEmpty) + delegatedUserId.openOr { val authenticatedUserId = user.map(_.userId).openOr("") - val grantingHumanUserId = for { - callerResourceUser <- code.model.dataAccess.ResourceUser.find( - net.liftweb.mapper.By(code.model.dataAccess.ResourceUser.userId_, authenticatedUserId)) - consentId <- net.liftweb.common.Full(callerResourceUser.CreatedByConsentId.get) - .filter(id => id != null && id.nonEmpty) - consent <- code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) - } yield consent.userId - grantingHumanUserId.filter(_.nonEmpty).openOr(authenticatedUserId) + // The resolver (Users.onBehalfOfUserIdOf) owns the consent chain; a Failure there (invariant + // broken) is already logged and, at this String-typed level, can only fall back to the caller. + code.users.Users.users.vend.onBehalfOfUserIdOf(authenticatedUserId).openOr(authenticatedUserId) } } def userPrimaryKey: UserPrimaryKey = user.map(_.userPrimaryKey).openOrThrowException(AuthenticatedUserIsRequired) diff --git a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala index 7d032cb521..417b97199b 100644 --- a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala +++ b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala @@ -172,31 +172,23 @@ object MappedEntitlementsProvider extends EntitlementProvider with MdcLoggable { // no caller ever passed it, and the check ignored super admins, whose // granting rights are virtual and have no rows to find.) - // On-behalf-of guard: a consent user (the per-consent principal a Consent-JWT - // authenticates as; its ResourceUser row carries CreatedByConsentId) must not - // accumulate durable roles — they strand when the consent dies, invisible to the - // human's next consent (see the simon.bank creator-grant incident, 2026-08-31). - // Any grant targeting one is redirected to the consent's granting human. The one - // legitimate writer of consent-user rows is the consent engine copying the - // consent's own scope, which tags itself Constant.consent_user and is exempt. - val targetUserId = - if (createdByProcess == code.api.Constant.consent_user) userId - else { - val grantingHumanUserId = for { - resourceUser <- code.model.dataAccess.ResourceUser.find( - By(code.model.dataAccess.ResourceUser.userId_, userId)) - consentId <- Full(resourceUser.CreatedByConsentId.get) - .filter(id => id != null && id.nonEmpty) - consent <- code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) - humanUserId <- Full(consent.userId).filter(id => id != null && id.nonEmpty) - } yield humanUserId - grantingHumanUserId match { - case Full(humanUserId) => - logger.warn(s"addEntitlement: target user $userId is a consent user; granting role '$roleName' (bankId '$bankId', createdByProcess '$createdByProcess') to its granting human $humanUserId instead") - humanUserId - case _ => userId - } - } + // On-behalf-of guard: a consent user (its ResourceUser row carries CreatedByConsentId) must + // not accumulate durable roles — they strand when the consent dies, invisible to the + // on-behalf-of user's next consent (see the simon.bank creator-grant incident, 2026-08-31). + // The attribution policy (code.users.UserReference) decides: the consent engine copying the + // consent's own scope tags itself Constant.consent_user and keeps the consent user + // (ConsentEntitlementUser); every other grant is written to the on-behalf-of user + // (EntitlementUser). The resolver logs the redirect. ON_BEHALF_OF_USER_ID_PLAN.md. + val ref = + if (createdByProcess == code.api.Constant.consent_user) code.users.UserReference.ConsentEntitlementUser + else code.users.UserReference.EntitlementUser + val targetUserId = code.users.Users.users.vend.attributedUserId(userId, ref) match { + case Full(id) => id + case f: Failure => return f + case _ => return Failure(s"addEntitlement: could not attribute user $userId") + } + if (targetUserId != userId) + logger.warn(s"addEntitlement: role '$roleName' (bankId '$bankId', createdByProcess '$createdByProcess') requested for consent user $userId is granted to its on-behalf-of user $targetUserId") def addEntitlementToUser(): Box[MappedEntitlement] = { val entitlement = MappedEntitlement.create diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index f64adb0f15..965bd79b6c 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -115,6 +115,17 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with } // Note: We don't save transaction_ids, status and challenge here. + // Record both: mUserId = the authenticated user, mOnBehalfOfUserId = who the payment is for. + // One attribution call (UserReference.TransactionRequest) gives both; the request layer's + // consentCreator / consenter take precedence over the DB chain, as in CallContext.onBehalfOfUserId. + val transactionRequestAttribution: Option[code.users.Attribution] = for { + cc <- callContext + user <- cc.user.toOption + a <- code.users.Users.users.vend.attributionOf(user.userId, code.users.UserReference.TransactionRequest).toOption + } yield cc.consentCreator.or(cc.consenter).map(_.userId).filter(_.nonEmpty) match { + case Full(delegated) => a.copy(onBehalfOfUserId = delegated) + case _ => a + } val mappedTransactionRequest = MappedTransactionRequest.create //transaction request fields: @@ -168,8 +179,8 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with .mConsentReferenceId(consentReferenceIdOption.getOrElse(null)) .mApiVersion(apiVersion.getOrElse(null)) .mApiStandard(apiStandard.getOrElse(null)) - .mUserId(callContext.flatMap(_.user.map(_.userId)).getOrElse(null)) - .mOnBehalfOfUserId(callContext.flatMap(cc => cc.consentCreator.or(cc.consenter).map(_.userId)).getOrElse(null)) + .mUserId(transactionRequestAttribution.map(_.userId).getOrElse(null)) + .mOnBehalfOfUserId(transactionRequestAttribution.map(_.onBehalfOfUserId).getOrElse(null)) .mConsumerId(callContext.flatMap(_.consumer.map(_.consumerId.get)).getOrElse(null)) // Explicit originator fields (FATF Rec 16, OPEN_CORRIDOR_PROMISE type only — null otherwise). diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index b720b10120..4e13f94bd3 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -11,7 +11,7 @@ import code.model.dataAccess.{AuthUser, ResourceUser} import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{User, UserPrimaryKey} -import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} import net.liftweb.mapper._ import net.liftweb.util.Helpers @@ -21,6 +21,84 @@ import scala.concurrent.Future object LiftUsers extends Users with MdcLoggable{ + // ---- on-behalf-of resolution (ON_BEHALF_OF_USER_ID_PLAN.md, Phase 1) ---------------------- + + /** What the chain resolved to, and whether the answer is stable enough to cache. */ + private case class Resolved(onBehalfOfUserId: Box[String], consentId: Option[String], cacheable: Boolean) + + /** Non-empty, bound answers only: the consent -> human binding never changes once set. The + * "consent has no human yet" answer (BG/UK before authorisation) must not be pinned, or a + * consent bound a minute later stays on the consent user for the TTL. */ + private lazy val onBehalfOfCacheTtlSeconds: Long = + APIUtil.getPropsAsLongValue("on_behalf_of_user_id.cache_ttl_seconds", 600L) + private lazy val onBehalfOfCache: com.google.common.cache.Cache[String, Resolved] = + com.google.common.cache.CacheBuilder.newBuilder() + .expireAfterWrite(onBehalfOfCacheTtlSeconds, java.util.concurrent.TimeUnit.SECONDS) + .maximumSize(100000) + .build[String, Resolved]() + + private def nonBlank(s: String): Boolean = s != null && s.nonEmpty + + private def resolveOnBehalfOf(userId: String): Resolved = { + if (!nonBlank(userId)) return Resolved(Full(userId), None, cacheable = false) + val cached = if (onBehalfOfCacheTtlSeconds > 0) Option(onBehalfOfCache.getIfPresent(userId)) else None + if (cached.isDefined) return cached.get + val resolved: Resolved = ResourceUser.find(By(ResourceUser.userId_, userId)) match { + case Full(ru) if ru.isConsentUser => + val consentId = ru.CreatedByConsentId.get + code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) match { + case Full(consent) if nonBlank(consent.userId) => + ResourceUser.find(By(ResourceUser.userId_, consent.userId)) match { + case Full(target) if target.isOriginalUser => + Resolved(Full(consent.userId), Some(consentId), cacheable = true) + case Full(_) => + logger.warn(s"onBehalfOfUserIdOf: consent user $userId's consent $consentId names ${consent.userId}, which is itself a consent user — invariant broken, refusing") + Resolved(Failure(s"${ErrorMessages.InvalidUserId} consent $consentId names a consent user as its on-behalf-of user"), Some(consentId), cacheable = false) + case _ => + logger.warn(s"onBehalfOfUserIdOf: consent user $userId's consent $consentId names unknown user ${consent.userId}; keeping $userId (fails closed)") + Resolved(Full(userId), Some(consentId), cacheable = false) + } + case Full(_) => + logger.warn(s"onBehalfOfUserIdOf: consent user $userId's consent $consentId has no human yet (not authorised); keeping $userId (fails closed, not cached)") + Resolved(Full(userId), Some(consentId), cacheable = false) + case _ => + logger.warn(s"onBehalfOfUserIdOf: consent user $userId names consent $consentId, which does not exist; keeping $userId (fails closed)") + Resolved(Full(userId), Some(consentId), cacheable = false) + } + case Full(_) => Resolved(Full(userId), None, cacheable = true) + case _ => + logger.warn(s"onBehalfOfUserIdOf: no ResourceUser $userId; keeping it (fails closed)") + Resolved(Full(userId), None, cacheable = false) + } + if (resolved.cacheable && onBehalfOfCacheTtlSeconds > 0) onBehalfOfCache.put(userId, resolved) + resolved + } + + override def onBehalfOfUserIdOf(userId: String): Box[String] = resolveOnBehalfOf(userId).onBehalfOfUserId + + override def attributionOf(userId: String, ref: UserReference): Box[Attribution] = ref.policy match { + case AttributionPolicy.KeepUserId => + Full(Attribution(userId, userId, None, ref)) + case AttributionPolicy.UseOnBehalfOfUserId => + val r = resolveOnBehalfOf(userId) + r.onBehalfOfUserId.map { h => + val a = Attribution(userId, h, r.consentId, ref) + if (a.isDelegated) + logger.warn(s"attribution ${ref.name}: user $userId is a consent user (consent ${r.consentId.getOrElse("?")}); writing on-behalf-of user $h to ${ref.mapperClass}.${ref.fields.mkString("/")}") + a + } + case AttributionPolicy.Reject => + val r = resolveOnBehalfOf(userId) + r.onBehalfOfUserId.flatMap { h => + if (h == userId) Full(Attribution(userId, h, r.consentId, ref)) + else { + logger.warn(s"attribution ${ref.name}: user $userId is a consent user (on behalf of $h); a consent user must not write ${ref.mapperClass}.${ref.fields.mkString("/")} — rejected") + Failure(s"${ErrorMessages.InvalidUserId} ${ref.name}: user $userId is a consent user; this action must be performed by the user it acts for ($h)") + } + } + } + + //UserId here is the resourceuser.id field def getUserByResourceUserId(id : Long) : Box[User] = { ResourceUser.find(id) ?~ { s"user $id not found"} diff --git a/obp-api/src/main/scala/code/users/UserReference.scala b/obp-api/src/main/scala/code/users/UserReference.scala new file mode 100644 index 0000000000..84dd382491 --- /dev/null +++ b/obp-api/src/main/scala/code/users/UserReference.scala @@ -0,0 +1,226 @@ +package code.users + +/** + * Attribution policy: what a user-reference column stores when the caller is a consent user. + * Design and vocabulary: OBP-API/ON_BEHALF_OF_USER_ID_PLAN.md ("The policy file"). + * + * - KeepUserId the authenticated user's own id; no resolver + * - UseOnBehalfOfUserId the on-behalf-of user's id, via Users.onBehalfOfUserIdOf + * - Reject a consent user must not do this at all: Failure -> 400 + */ +sealed trait AttributionPolicy +object AttributionPolicy { + case object KeepUserId extends AttributionPolicy + case object UseOnBehalfOfUserId extends AttributionPolicy + case object Reject extends AttributionPolicy +} + +/** + * What a provider gets back from Users.attributionOf: everything it should store, plus the + * facts the resolver logged. userId is the authenticated caller; onBehalfOfUserId is who owns + * what the call creates (== userId for an original user acting alone; for a KeepUserId + * reference the resolver is not consulted and it is simply userId). + */ +case class Attribution( + userId: String, + onBehalfOfUserId: String, + consentId: Option[String], + ref: UserReference +) { + def isDelegated: Boolean = userId != onBehalfOfUserId + /** The single value for the column(s) `ref` names, per its policy. */ + def userIdToStore: String = ref.policy match { + case AttributionPolicy.UseOnBehalfOfUserId => onBehalfOfUserId + case _ => userId + } +} + +/** + * One value per user-reference column (or per record-both table). This file IS the policy + * table: Users.attributionOf reads it at runtime, and UserReferenceAttributionPolicyTest + * (frozen-style) asserts every Mapper column whose name looks like a user reference is named + * by exactly one value here (or listed in notUserIdColumns). A new table fails until sorted. + * + * mapperClass is the fully-qualified Mapper class; fields are its field object names. + */ +sealed abstract class UserReference( + val policy: AttributionPolicy, + val mapperClass: String, + val fields: List[String], + val note: String = "" +) { + def name: String = getClass.getSimpleName.stripSuffix("$") +} + +object UserReference { + import AttributionPolicy._ + + // ---- KeepUserId: authorisation materialisation and audit of the actor + case object AccountAccessUser extends UserReference(KeepUserId , "code.views.system.AccountAccess", List("user_fk"), "views copied from the consent JWT each request; has lifecycle GC") + case object ConsentEntitlementUser extends UserReference(KeepUserId , "code.entitlement.MappedEntitlement", List("mUserId"), "only when createdByProcess == consent_user: the consent engine copying the consent's own scope") + case object EntitlementGrantedBy extends UserReference(KeepUserId , "code.entitlement.MappedEntitlement", List("mGrantedByUserId"), "audit: who granted") + case object UserLocksUser extends UserReference(KeepUserId , "code.userlocks.UserLocks", List("UserId"), "lock the authenticated user") + case object ExpectedChallengeAnswerUser extends UserReference(KeepUserId , "code.transactionChallenge.MappedExpectedChallengeAnswer", List("ExpectedUserId"), "the challenge is answered by the initiating user") + case object ChatMessageSender extends UserReference(KeepUserId , "code.chat.ChatMessage", List("SenderUserId"), "sender = the authenticated user is truthful") + case object PemUsageLastUser extends UserReference(KeepUserId , "code.api.pemusage.PemUsage", List("LastUserId"), "audit") + case object MetricUser extends UserReference(KeepUserId , "code.metrics.MappedMetric", List("userId"), "record both: on-behalf-of via consent_reference_id at read time") + case object MetricArchiveUser extends UserReference(KeepUserId , "code.metrics.MetricArchive", List("userId"), "as MetricUser") + case object ConnectorTraceUser extends UserReference(KeepUserId , "code.metrics.ConnectorTrace", List("userId"), "as MetricUser") + case object DynamicDataAccessGrantedBy extends UserReference(KeepUserId , "code.DynamicData.DynamicDataAccess", List("GrantedBy"), "audit: who granted") + case object AuthUserResourceUser extends UserReference(KeepUserId , "code.model.dataAccess.AuthUser", List("user"), "login row -> its own ResourceUser; not attribution") + case object OpenIDConnectTokenUser extends UserReference(KeepUserId , "code.token.OpenIDConnectToken", List("AuthUserPrimaryKey"), "token belongs to the login; not attribution") + case object UserRefreshesUser extends UserReference(KeepUserId , "code.UserRefreshes.MappedUserRefreshes", List("mUserId"), "operational: refresh of the authenticated user's own account list") + + // ---- UseOnBehalfOfUserId: ownership / attribution (record-both tables list both columns) + case object TransactionRequest extends UserReference(UseOnBehalfOfUserId, "code.transactionrequests.MappedTransactionRequest", List("mUserId", "mOnBehalfOfUserId"), "record both: mUserId = userId, mOnBehalfOfUserId = onBehalfOfUserId") + case object EntitlementUser extends UserReference(UseOnBehalfOfUserId, "code.entitlement.MappedEntitlement", List("mUserId"), "the role holder; the consent-engine case is ConsentEntitlementUser") + case object AccountHolderUser extends UserReference(UseOnBehalfOfUserId, "code.accountholders.MapperAccountHolders", List("user")) + case object UserCustomerLinkUser extends UserReference(UseOnBehalfOfUserId, "code.usercustomerlinks.MappedUserCustomerLink", List("mUserId")) + case object AccountApplicationUser extends UserReference(UseOnBehalfOfUserId, "code.accountapplication.MappedAccountApplication", List("mUserId")) + case object AccountAccessRequestRequestor extends UserReference(UseOnBehalfOfUserId, "code.accountaccessrequest.AccountAccessRequest", List("RequestorUserId")) + case object AccountAccessRequestTarget extends UserReference(UseOnBehalfOfUserId, "code.accountaccessrequest.AccountAccessRequest", List("TargetUserId"), "explicit target: a consent user named here is rejected at the endpoint") + case object AccountAccessRequestChecker extends UserReference(UseOnBehalfOfUserId, "code.accountaccessrequest.AccountAccessRequest", List("CheckerUserId")) + case object EntitlementRequestUser extends UserReference(UseOnBehalfOfUserId, "code.entitlementrequest.MappedEntitlementRequest", List("mUserId")) + case object UserScopeUser extends UserReference(UseOnBehalfOfUserId, "code.scope.MappedUserScope", List("mUserId")) + case object ApiCollectionUser extends UserReference(UseOnBehalfOfUserId, "code.apicollection.ApiCollection", List("UserId")) + case object UserAttributeUser extends UserReference(UseOnBehalfOfUserId, "code.users.UserAttribute", List("UserId")) + case object UserAgreementUser extends UserReference(UseOnBehalfOfUserId, "code.users.UserAgreement", List("UserId")) + case object UserInitActionUser extends UserReference(UseOnBehalfOfUserId, "code.users.UserInitAction", List("UserId")) + case object UserAuthContextUser extends UserReference(UseOnBehalfOfUserId, "code.context.MappedUserAuthContext", List("mUserId"), "consent copies the on-behalf-of user's contexts into ConsentAuthContext separately") + case object UserAuthContextUpdateUser extends UserReference(UseOnBehalfOfUserId, "code.context.MappedUserAuthContextUpdate", List("mUserId")) + case object DynamicEntityUser extends UserReference(UseOnBehalfOfUserId, "code.dynamicEntity.DynamicEntity", List("UserId")) + case object DynamicDataUser extends UserReference(UseOnBehalfOfUserId, "code.DynamicData.DynamicData", List("UserId")) + case object DynamicDataAccessUser extends UserReference(UseOnBehalfOfUserId, "code.DynamicData.DynamicDataAccess", List("UserId")) + case object DynamicEndpointUser extends UserReference(UseOnBehalfOfUserId, "code.DynamicEndpoint.DynamicEndpoint", List("UserId")) + case object DynamicResourceDocCreator extends UserReference(UseOnBehalfOfUserId, "code.dynamicResourceDoc.DynamicResourceDoc", List("CreatedByUserId", "UpdatedByUserId")) + case object DynamicMessageDocCreator extends UserReference(UseOnBehalfOfUserId, "code.dynamicMessageDoc.DynamicMessageDoc", List("CreatedByUserId", "UpdatedByUserId")) + case object ConnectorMethodCreator extends UserReference(UseOnBehalfOfUserId, "code.connectormethod.ConnectorMethod", List("CreatedByUserId", "UpdatedByUserId")) + case object AbacRuleCreator extends UserReference(UseOnBehalfOfUserId, "code.abacrule.AbacRule", List("CreatedByUserId", "UpdatedByUserId")) + case object CounterpartyCreator extends UserReference(UseOnBehalfOfUserId, "code.metadata.counterparties.MappedCounterparty", List("mCreatedByUserId")) + case object CounterpartyWhereTagUser extends UserReference(UseOnBehalfOfUserId, "code.metadata.counterparties.MappedCounterpartyWhereTag", List("user")) + case object BankCreator extends UserReference(UseOnBehalfOfUserId, "code.model.dataAccess.MappedBank", List("CreatedByUserId"), "creator grant already resolved at the endpoint") + case object OrganisationCreator extends UserReference(UseOnBehalfOfUserId, "code.organisation.Organisation", List("CreatedByUserId")) + case object PayeeLookupCreator extends UserReference(UseOnBehalfOfUserId, "code.payeelookup.PayeeLookup", List("CreatedByUserId")) + case object RoutingSchemeCreator extends UserReference(UseOnBehalfOfUserId, "code.routingscheme.RoutingScheme", List("CreatedByUserId")) + case object UtilityPaymentCallbackCreator extends UserReference(UseOnBehalfOfUserId, "code.utilitypayment.UtilityPaymentCallback", List("CreatedByUserId")) + case object StandingOrderUser extends UserReference(UseOnBehalfOfUserId, "code.standingorders.StandingOrder", List("UserId")) + case object DirectDebitUser extends UserReference(UseOnBehalfOfUserId, "code.directdebit.DirectDebit", List("UserId")) + case object MandateCreator extends UserReference(UseOnBehalfOfUserId, "code.mandate.Mandate", List("CreatedByUserId", "UpdatedByUserId")) + case object SignatoryPanelUsers extends UserReference(UseOnBehalfOfUserId, "code.mandate.SignatoryPanel", List("UserIds"), "list of user ids") + case object AccountWebhookCreator extends UserReference(UseOnBehalfOfUserId, "code.webhook.MappedAccountWebhook", List("mCreatedByUserId")) + case object SystemAccountNotificationWebhookCreator extends UserReference(UseOnBehalfOfUserId, "code.webhook.SystemAccountNotificationWebhook", List("CreatedByUserId")) + case object BankAccountNotificationWebhookCreator extends UserReference(UseOnBehalfOfUserId, "code.webhook.BankAccountNotificationWebhook", List("CreatedByUserId")) + case object ChatRoomCreator extends UserReference(UseOnBehalfOfUserId, "code.chat.ChatRoom", List("CreatedByUserId"), "Portal chat: a human's room") + case object ChatParticipantUser extends UserReference(UseOnBehalfOfUserId, "code.chat.Participant", List("UserId")) + case object ChatReactionUser extends UserReference(UseOnBehalfOfUserId, "code.chat.Reaction", List("UserId")) + case object ChatEmailDigestStateUser extends UserReference(UseOnBehalfOfUserId, "code.chat.ChatEmailDigestState", List("UserId")) + case object ChatMessageMentionedUsers extends UserReference(UseOnBehalfOfUserId, "code.chat.ChatMessage", List("MentionedUserIds"), "explicit targets, humans by construction") + case object CrmEventUser extends UserReference(UseOnBehalfOfUserId, "code.crm.MappedCrmEvent", List("mUserId")) + case object KycCheckUser extends UserReference(UseOnBehalfOfUserId, "code.kycchecks.MappedKycCheck", List("user"), "the customer's user") + case object KycCheckStaff extends UserReference(UseOnBehalfOfUserId, "code.kycchecks.MappedKycCheck", List("mStaffUserId"), "staff = human operator") + case object KycDocumentUser extends UserReference(UseOnBehalfOfUserId, "code.kycdocuments.MappedKycDocument", List("user")) + case object KycStatusUser extends UserReference(UseOnBehalfOfUserId, "code.kycstatuses.MappedKycStatus", List("user")) + case object SocialMediaUser extends UserReference(UseOnBehalfOfUserId, "code.socialmedia.MappedSocialMedia", List("user")) + case object CustomerMessageUser extends UserReference(UseOnBehalfOfUserId, "code.customer.MappedCustomerMessage", List("user")) + case object MeetingCustomerUser extends UserReference(UseOnBehalfOfUserId, "code.meetings.MappedMeeting", List("mCustomerUserId")) + case object MeetingStaffUser extends UserReference(UseOnBehalfOfUserId, "code.meetings.MappedMeeting", List("mStaffUserId"), "staff = human operator") + case object TagUser extends UserReference(UseOnBehalfOfUserId, "code.metadata.tags.MappedTag", List("user")) + case object WhereTagUser extends UserReference(UseOnBehalfOfUserId, "code.metadata.wheretags.MappedWhereTag", List("user")) + case object TransactionImageUser extends UserReference(UseOnBehalfOfUserId, "code.metadata.transactionimages.MappedTransactionImage", List("user")) + + // ---- Reject: a consent user must not do this at all + case object ConsentCreator extends UserReference(Reject , "code.consent.MappedConsent", List("mUserId"), "a consent user creating a consent = nested delegation") + case object OAuthConsumerCreator extends UserReference(Reject , "code.model.Consumer", List("createdByUserId"), "credentials outlive the consent") + case object OAuthTokenUser extends UserReference(Reject , "code.model.Token", List("userForeignKey"), "credentials outlive the consent") + + /** Every reference; the frozen test walks this. */ + lazy val all: List[UserReference] = List( + AccountAccessUser, + ConsentEntitlementUser, + EntitlementGrantedBy, + UserLocksUser, + ExpectedChallengeAnswerUser, + ChatMessageSender, + PemUsageLastUser, + MetricUser, + MetricArchiveUser, + ConnectorTraceUser, + DynamicDataAccessGrantedBy, + AuthUserResourceUser, + OpenIDConnectTokenUser, + UserRefreshesUser, + TransactionRequest, + EntitlementUser, + AccountHolderUser, + UserCustomerLinkUser, + AccountApplicationUser, + AccountAccessRequestRequestor, + AccountAccessRequestTarget, + AccountAccessRequestChecker, + EntitlementRequestUser, + UserScopeUser, + ApiCollectionUser, + UserAttributeUser, + UserAgreementUser, + UserInitActionUser, + UserAuthContextUser, + UserAuthContextUpdateUser, + DynamicEntityUser, + DynamicDataUser, + DynamicDataAccessUser, + DynamicEndpointUser, + DynamicResourceDocCreator, + DynamicMessageDocCreator, + ConnectorMethodCreator, + AbacRuleCreator, + CounterpartyCreator, + CounterpartyWhereTagUser, + BankCreator, + OrganisationCreator, + PayeeLookupCreator, + RoutingSchemeCreator, + UtilityPaymentCallbackCreator, + StandingOrderUser, + DirectDebitUser, + MandateCreator, + SignatoryPanelUsers, + AccountWebhookCreator, + SystemAccountNotificationWebhookCreator, + BankAccountNotificationWebhookCreator, + ChatRoomCreator, + ChatParticipantUser, + ChatReactionUser, + ChatEmailDigestStateUser, + ChatMessageMentionedUsers, + CrmEventUser, + KycCheckUser, + KycCheckStaff, + KycDocumentUser, + KycStatusUser, + SocialMediaUser, + CustomerMessageUser, + MeetingCustomerUser, + MeetingStaffUser, + TagUser, + WhereTagUser, + TransactionImageUser, + ConsentCreator, + OAuthConsumerCreator, + OAuthTokenUser + ) + + /** Mapper fields the frozen test's name pattern matches but which are not user ids. */ + val notUserIdColumns: List[(String, String, String)] = List( + ("code.model.dataAccess.MappedBankAccount", "holder", "free-text holder name"), + ("code.transaction.MappedTransaction", "counterpartyAccountHolder", "free-text name"), + ("code.accountaccessrequest.AccountAccessRequest", "CheckerComment", "text"), + ("code.kycchecks.MappedKycCheck", "mStaffName", "text"), + ("code.meetings.MappedMeeting", "mStaffToken", "token"), + ("code.entitlement.MappedEntitlement", "mCreatedByProcess", "process tag"), + ("code.model.dataAccess.ResourceUser", "userId_", "the user's own id"), + ("code.model.dataAccess.ResourceUser", "CreatedByConsentId", "consent id"), + ("code.model.dataAccess.ResourceUser", "CreatedByUserInvitationId", "invitation id") + ) + + def byPolicy(p: AttributionPolicy): List[UserReference] = all.filter(_.policy == p) +} diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index 1c6427b968..8b48b1e3e4 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -85,6 +85,30 @@ trait Users { def createUnsavedResourceUser(provider: String, providerId: Option[String], name: Option[String], email: Option[String], userId: Option[String]) : Box[ResourceUser] + // ---- on-behalf-of resolution (ON_BEHALF_OF_USER_ID_PLAN.md, Phase 1) ---------------------- + + /** The on-behalf-of user id for `userId`. + * consent user -> the consent's userId (read at call time: BG/UK consents bind their human + * only at authorisation, so it is never copied at creation) + * original user -> userId unchanged + * Fails closed: unknown user / dangling consent id / consent with no human yet -> userId (+ WARN). + * Invariant: the result is an original user (isOriginalUser); a consent whose user is itself a + * consent user is a data bug -> WARN + Failure, the one case that cannot fall back. + * Takes only the id on purpose: nothing request-asserted (body/header/query) can steer it. */ + def onBehalfOfUserIdOf(userId: String): Box[String] + + /** True when `userId` acts for itself and may own durable state. */ + def actsForSelf(userId: String): Boolean = onBehalfOfUserIdOf(userId).exists(_ == userId) + + /** Attribution for writing the column(s) `ref` names as `userId`. Applies `ref.policy`: + * KeepUserId -> Full(userId as both), resolver not consulted + * UseOnBehalfOfUserId -> Full(resolved), WARN naming `ref` when delegated + * Reject -> Full if `userId` acts for itself, else Failure(InvalidUserId ...) */ + def attributionOf(userId: String, ref: UserReference): Box[Attribution] + + /** Convenience for single-column writers: the one value to store. */ + def attributedUserId(userId: String, ref: UserReference): Box[String] = attributionOf(userId, ref).map(_.userIdToStore) + def saveResourceUser(resourceUser: ResourceUser) : Box[ResourceUser] def deleteResourceUser(userId: Long) : Box[Boolean] diff --git a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala index 8fb670967e..7183ffec9d 100644 --- a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala +++ b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala @@ -4,8 +4,8 @@ import code.api.util.APIUtil.generateUUID import code.consent.MappedConsent import code.model.dataAccess.ResourceUser import code.setup.ServerSetup -import code.users.Users -import net.liftweb.common.Full +import code.users.{AttributionPolicy, UserReference, Users} +import net.liftweb.common.{Failure, Full} import org.scalatest.Tag /** @@ -110,4 +110,127 @@ class AgentDelegationTest extends ServerSetup { ).onBehalfOfUserId shouldBe explicitHuman.userId } } + + feature("Users.onBehalfOfUserIdOf — the resolver") { + + scenario("an original user resolves to itself", AgentDelegationTag) { + val human = createUser() + Users.users.vend.onBehalfOfUserIdOf(human.userId) shouldBe Full(human.userId) + Users.users.vend.actsForSelf(human.userId) shouldBe true + } + + scenario("a consent user resolves to the consent's user", AgentDelegationTag) { + val human = createUser() + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + Users.users.vend.onBehalfOfUserIdOf(agent.userId) shouldBe Full(human.userId) + Users.users.vend.actsForSelf(agent.userId) shouldBe false + } + + scenario("a dangling consent id keeps the caller (fails closed)", AgentDelegationTag) { + val agent = createUser(createdByConsentId = Some(generateUUID())) + Users.users.vend.onBehalfOfUserIdOf(agent.userId) shouldBe Full(agent.userId) + } + + scenario("an unknown user id keeps itself (fails closed)", AgentDelegationTag) { + val id = generateUUID() + Users.users.vend.onBehalfOfUserIdOf(id) shouldBe Full(id) + } + + scenario("BG-style: consent with no human yet keeps the caller, and is NOT pinned in the cache", AgentDelegationTag) { + val consent = MappedConsent.create.saveMe() // mUserId empty until authorisation + val agent = createUser(createdByConsentId = Some(consent.consentId)) + Users.users.vend.onBehalfOfUserIdOf(agent.userId) shouldBe Full(agent.userId) + val human = createUser() + consent.mUserId(human.userId).saveMe() // authorisation binds the human + Users.users.vend.onBehalfOfUserIdOf(agent.userId) shouldBe Full(human.userId) + } + + scenario("invariant: a consent whose user is itself a consent user is refused, not resolved", AgentDelegationTag) { + val human = createUser() + val consent1 = MappedConsent.create.mUserId(human.userId).saveMe() + val agent1 = createUser(createdByConsentId = Some(consent1.consentId)) + val consent2 = MappedConsent.create.mUserId(agent1.userId).saveMe() // names a consent user: data bug + val agent2 = createUser(createdByConsentId = Some(consent2.consentId)) + Users.users.vend.onBehalfOfUserIdOf(agent2.userId) shouldBe a[Failure] + // and CallContext falls back to the caller rather than throwing + CallContext(user = Full(agent2)).onBehalfOfUserId shouldBe agent2.userId + } + } + + feature("Users.attributionOf — the policy-aware entry point") { + + scenario("KeepUserId stores the caller and does not consult the resolver", AgentDelegationTag) { + val human = createUser() + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + val a = Users.users.vend.attributionOf(agent.userId, UserReference.ConsentEntitlementUser).openOrThrowException("expected Full") + a.userIdToStore shouldBe agent.userId + a.onBehalfOfUserId shouldBe agent.userId + a.isDelegated shouldBe false + a.consentId shouldBe None + } + + scenario("UseOnBehalfOfUserId stores the on-behalf-of user and reports the consent", AgentDelegationTag) { + val human = createUser() + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + val a = Users.users.vend.attributionOf(agent.userId, UserReference.EntitlementUser).openOrThrowException("expected Full") + a.userId shouldBe agent.userId + a.onBehalfOfUserId shouldBe human.userId + a.userIdToStore shouldBe human.userId + a.isDelegated shouldBe true + a.consentId shouldBe Some(consent.consentId) + Users.users.vend.attributedUserId(agent.userId, UserReference.EntitlementUser) shouldBe Full(human.userId) + } + + scenario("UseOnBehalfOfUserId for an original user is a no-op with no consent", AgentDelegationTag) { + val human = createUser() + val a = Users.users.vend.attributionOf(human.userId, UserReference.AccountHolderUser).openOrThrowException("expected Full") + a.userIdToStore shouldBe human.userId + a.isDelegated shouldBe false + a.consentId shouldBe None + } + + scenario("Reject is Full for an original user and Failure for a consent user", AgentDelegationTag) { + val human = createUser() + Users.users.vend.attributionOf(human.userId, UserReference.ConsentCreator).map(_.userIdToStore) shouldBe Full(human.userId) + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + val rejected = Users.users.vend.attributionOf(agent.userId, UserReference.ConsentCreator) + rejected shouldBe a[Failure] + rejected.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidUserId) + } + + scenario("the policy file is complete: every reference has a policy, a class and at least one field", AgentDelegationTag) { + UserReference.all should not be empty + UserReference.all.map(_.name).distinct.size shouldBe UserReference.all.size + UserReference.all.foreach { r => + r.fields should not be empty + Class.forName(r.mapperClass) // resolves, or the reference names a class that does not exist + } + UserReference.byPolicy(AttributionPolicy.Reject).map(_.name) should contain allOf ("ConsentCreator", "OAuthConsumerCreator") + } + } + + feature("addEntitlement goes through the attribution policy") { + + scenario("a grant targeting a consent user lands on its on-behalf-of user", AgentDelegationTag) { + val human = createUser() + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + val role = "CanGetConfig" + val e = code.entitlement.Entitlement.entitlement.vend.addEntitlement("", agent.userId, role).openOrThrowException("expected the grant") + e.userId shouldBe human.userId + } + + scenario("the consent engine's own scope copy stays on the consent user", AgentDelegationTag) { + val human = createUser() + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + val role = "CanGetConfig" + val e = code.entitlement.Entitlement.entitlement.vend.addEntitlement("", agent.userId, role, createdByProcess = code.api.Constant.consent_user).openOrThrowException("expected the grant") + e.userId shouldBe agent.userId + } + } } From 73cceba364fac5c0bf247a6ff74233f45a276b40 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 2 Sep 2026 16:49:37 +0200 Subject: [PATCH 4/5] Product Subscriptions --- .../resources/props/sample.props.template | 8 +- .../main/scala/bootstrap/liftweb/Boot.scala | 5 + .../SwaggerDefinitionsJSON.scala | 1 + .../main/scala/code/api/cache/Caching.scala | 9 +- .../main/scala/code/api/util/ApiRole.scala | 18 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 12 + .../main/scala/code/api/util/Glossary.scala | 48 +- .../main/scala/code/api/util/NewStyle.scala | 112 ++++ .../code/api/util/RateLimitingUtil.scala | 42 +- .../scala/code/api/v6_0_0/Http4s600.scala | 48 ++ .../scala/code/api/v7_0_0/Http4s700.scala | 555 ++++++++++++++++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 108 ++++ .../ApiProductSubscription.scala | 84 +++ .../ApiProductSubscriptionEnforcer.scala | 147 +++++ .../ApiProductSubscriptionScope.scala | 45 ++ .../ApiProductSubscriptionsProvider.scala | 115 ++++ .../ApiProductSubscriptionAttribute.scala | 36 ++ ...roductSubscriptionAttributesProvider.scala | 80 +++ .../code/api/cache/CacheKeyFormatTest.scala | 27 + .../code/api/v6_0_0/RateLimitsTest.scala | 145 ++++- .../v7_0_0/ApiProductSubscriptionTest.scala | 514 ++++++++++++++++ 22 files changed, 2131 insertions(+), 29 deletions(-) create mode 100644 obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscription.scala create mode 100644 obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionEnforcer.scala create mode 100644 obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionScope.scala create mode 100644 obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionsProvider.scala create mode 100644 obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttribute.scala create mode 100644 obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttributesProvider.scala create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/ApiProductSubscriptionTest.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 141dfb17b7..f71240b558 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1158,12 +1158,14 @@ featured_apis=elasticSearchWarehouseV300 # Default is now true. This property may be removed in a future version. # Set to false to use only system-wide defaults (not recommended) # use_consumer_limits=true -# In case isn't defined default value is 60 -# user_consumer_limit_anonymous_access=100 +# Per-hour limit for anonymous calls (no consumer). Default 1000. 0 blocks all anonymous access, -1 removes the limit. +# user_consumer_limit_anonymous_access=1000 # For the Rate Limiting feature we use Redis cache instance # In case isn't defined default value is root # rate_limiting.exclude_endpoints=root -## Default rate limiting for a new consumer +## Default rate limits for a consumer that has no rate limit records at all. +## -1 = unlimited, 0 = blocked, positive = max calls in the period. +## Once a consumer has any record, the record's values apply instead (its -1 is a literal unlimited). # rate_limiting_per_second = -1 # rate_limiting_per_minute = -1 # rate_limiting_per_hour = -1 diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index f91f111c61..c339fb6087 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -55,6 +55,8 @@ import code.apicollection.ApiCollection import code.apicollectionendpoint.ApiCollectionEndpoint import code.apiproduct.ApiProduct import code.apiproductattribute.ApiProductAttribute +import code.apiproductsubscription.{ApiProductSubscription, ApiProductSubscriptionScope} +import code.apiproductsubscriptionattribute.ApiProductSubscriptionAttribute import code.atmattribute.AtmAttribute import code.atms.MappedAtm import code.authtypevalidation.AuthenticationTypeValidation @@ -996,6 +998,9 @@ object ToSchemify extends MdcLoggable { ApiCollectionEndpoint, ApiProduct, ApiProductAttribute, + ApiProductSubscription, + ApiProductSubscriptionScope, + ApiProductSubscriptionAttribute, FeaturedApiCollection, JsonSchemaValidation, AuthenticationTypeValidation, 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 a3c8cac50a..94d8e9d2c0 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 @@ -5392,6 +5392,7 @@ object SwaggerDefinitionsJSON { ) lazy val apiProductsJsonV600 = ApiProductsJsonV600(List(apiProductJsonV600)) + lazy val productJsonV600 = ProductJsonV600( bank_id = bankIdExample.value, product_code = productCodeExample.value, diff --git a/obp-api/src/main/scala/code/api/cache/Caching.scala b/obp-api/src/main/scala/code/api/cache/Caching.scala index 74dbee5a2f..73b3a1bd81 100644 --- a/obp-api/src/main/scala/code/api/cache/Caching.scala +++ b/obp-api/src/main/scala/code/api/cache/Caching.scala @@ -117,7 +117,12 @@ object Caching extends MdcLoggable { * @return Number of cache keys deleted */ def invalidateRateLimitCache(consumerId: String): Int = { - val pattern = s"${RATE_LIMIT_ACTIVE_PREFIX}${consumerId}_*" + // scalacache stores the entry as + // :code.api.cache.Redis.memoizeSyncWithRedis(Some())()() + // so the glob must be unanchored at the front, as "*getMethodRoutings*" is. Without the + // leading "*" this deleted nothing (silently) and a new or changed rate limit only took + // effect when the hour cache expired. Pinned by CacheKeyFormatTest. + val pattern = s"*${RATE_LIMIT_ACTIVE_PREFIX}${consumerId}_*" Redis.deleteKeysByPattern(pattern) } @@ -128,7 +133,7 @@ object Caching extends MdcLoggable { * @return Number of cache keys deleted */ def invalidateAllRateLimitCache(): Int = { - val pattern = s"${RATE_LIMIT_ACTIVE_PREFIX}*" + val pattern = s"*${RATE_LIMIT_ACTIVE_PREFIX}*" Redis.deleteKeysByPattern(pattern) } diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 7e808099fd..4a09586349 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -776,6 +776,24 @@ object ApiRole extends MdcLoggable{ case class CanDeleteApiProductAttribute(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteApiProductAttribute = CanDeleteApiProductAttribute() + // API Product Subscription roles, held at the product's bank (a billing adapter serving several + // banks is granted the role at each of them). Developers need none of these for their own + // consumers: ownership is enforced in the handler. See API_PRODUCT_SUBSCRIPTION_PLAN.md. + case class CanCreateApiProductSubscriptionAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateApiProductSubscriptionAtOneBank = CanCreateApiProductSubscriptionAtOneBank() + case class CanGetApiProductSubscriptionAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetApiProductSubscriptionAtOneBank = CanGetApiProductSubscriptionAtOneBank() + case class CanUpdateApiProductSubscriptionStatusAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateApiProductSubscriptionStatusAtOneBank = CanUpdateApiProductSubscriptionStatusAtOneBank() + case class CanDeleteApiProductSubscriptionAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteApiProductSubscriptionAtOneBank = CanDeleteApiProductSubscriptionAtOneBank() + case class CanCreateApiProductSubscriptionAttributeAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateApiProductSubscriptionAttributeAtOneBank = CanCreateApiProductSubscriptionAttributeAtOneBank() + case class CanUpdateApiProductSubscriptionAttributeAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateApiProductSubscriptionAttributeAtOneBank = CanUpdateApiProductSubscriptionAttributeAtOneBank() + case class CanDeleteApiProductSubscriptionAttributeAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteApiProductSubscriptionAttributeAtOneBank = CanDeleteApiProductSubscriptionAttributeAtOneBank() + case class CanCreateSystemView(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateSystemView = CanCreateSystemView() case class CanUpdateSystemView(requiresBankId: Boolean = false) extends ApiRole diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index 4f98f938de..a5da4c3017 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -74,6 +74,7 @@ object ApiTag { val apiTagProductCollection = ResourceDocTag("Product-Collection") val apiTagApiProduct = ResourceDocTag("Api-Product") val apiTagApiProductAttribute = ResourceDocTag("Api-Product-Attribute") + val apiTagApiProductSubscription = ResourceDocTag("Api-Product-Subscription") val apiTagOpenData = ResourceDocTag("Open-Data") val apiTagConsumer = ResourceDocTag("Consumer") val apiTagSearchWarehouse = ResourceDocTag("Data-Warehouse") 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 629207f760..11f2112496 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -486,6 +486,18 @@ object ErrorMessages { val ApiProductAttributeNotFound = "OBP-30503: ApiProductAttribute not found. Please specify a valid value for API_PRODUCT_ATTRIBUTE_ID." val CreateApiProductAttributeError = "OBP-30504: Could not create ApiProductAttribute." val DeleteApiProductAttributeError = "OBP-30505: Could not delete ApiProductAttribute." + // API Product Subscription (OBP-30560 .. OBP-30570) + val ApiProductSubscriptionNotFound = "OBP-30560: ApiProductSubscription not found. Please specify a valid value for API_PRODUCT_SUBSCRIPTION_ID." + val ApiProductSubscriptionAlreadyExists = "OBP-30561: ApiProductSubscription already exists. The Consumer already holds a non-cancelled subscription to this API Product." + val InvalidApiProductSubscriptionStatus = "OBP-30562: Invalid ApiProductSubscription status. Allowed values are: requested, active, past_due, suspended, cancelled." + val InvalidApiProductSubscriptionStatusTransition = "OBP-30563: Invalid ApiProductSubscription status transition." + val ConsumerNotOwnedByUser = "OBP-30564: The Consumer was not created by the current User. Please specify the CONSUMER_ID of one of your own Consumers." + val CreateApiProductSubscriptionError = "OBP-30565: Could not create ApiProductSubscription." + val UpdateApiProductSubscriptionError = "OBP-30566: Could not update ApiProductSubscription." + val DeleteApiProductSubscriptionError = "OBP-30567: Could not delete ApiProductSubscription." + val ApiProductSubscriptionAttributeNotFound = "OBP-30568: ApiProductSubscriptionAttribute not found. Please specify a valid value for API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID." + val CreateApiProductSubscriptionAttributeError = "OBP-30569: Could not create ApiProductSubscriptionAttribute." + val DeleteApiProductSubscriptionAttributeError = "OBP-30570: Could not delete ApiProductSubscriptionAttribute." val OrganisationNotFound = "OBP-30506: Organisation not found. Please specify a valid value for ORGANISATION_ID." val OrganisationAlreadyExists = "OBP-30507: Organisation already exists. Please specify a different value for ORGANISATION_ID." diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index c73732c20f..78fc58339a 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -221,9 +221,10 @@ object Glossary extends MdcLoggable { |│ │ │ │ |│ │ Logic: │ │ |│ │ 1. Query RateLimiting table for active records │ │ - |│ │ 2. If found: │ │ - |│ │ • Sum positive values (> 0) for each period │ │ - |│ │ • Return -1 if no positive values (unlimited) │ │ + |│ │ 2. If found, per period: │ │ + |│ │ • Ignore -1 values (unlimited rows add nothing) │ │ + |│ │ • Sum the rest; a sum of 0 -> blocked (429 on every call) │ │ + |│ │ • Nothing to sum (all -1) -> -1 (unlimited) │ │ |│ │ • Extract rate_limiting_ids │ │ |│ │ 3. If not found: │ │ |│ │ • Return system defaults from props │ │ @@ -270,7 +271,7 @@ object Glossary extends MdcLoggable { | |1. **Rate Limit Records**: Stored in the `RateLimiting` table with date ranges (from_date, to_date) |2. **Multiple Records**: A consumer can have multiple active rate limit records that overlap - |3. **Aggregation**: When multiple records are active, their limits are summed together (positive values only) + |3. **Aggregation**: When multiple records are active, per period: a `0` in any record blocks the period; otherwise the positive values are summed; otherwise (all `-1`) the period is unlimited |4. **Enforcement**: On every API request, the system checks Redis counters against the aggregated limits | |### Time Periods @@ -283,7 +284,12 @@ object Glossary extends MdcLoggable { |- **per_week_rate_limit**: Maximum requests per week |- **per_month_rate_limit**: Maximum requests per month | - |A value of `-1` means unlimited for that period. + |Each value means: + |- `0`: this record grants no calls for that period. Records are summed, so a `0` only blocks the Consumer when the sum over all of its records is 0 (for example when it is the Consumer's only record). A blocked period refuses every call with 429. This is how a suspended API Product Subscription stops a Consumer whose access came from that subscription alone. + |- `-1`: unlimited for that period. Once a record exists, `-1` is literal: the system default for that period does not apply. `-1` records add nothing to the sum. + |- a positive number: the maximum number of calls in that period. Overlapping records are summed. + | + |A Consumer with no records at all gets the system defaults (see below). | |### HTTP Headers | @@ -320,7 +326,7 @@ object Glossary extends MdcLoggable { |- `rate_limiting_per_week` |- `rate_limiting_per_month` | - |Default value: `-1` (unlimited) + |Default value: `-1` (unlimited). These defaults apply only to Consumers with no active records; a default of `0` would block every such Consumer. | |### Example | @@ -330,6 +336,14 @@ object Glossary extends MdcLoggable { | |**Aggregated limits**: 15 requests/second, 150 requests/minute | + |The same consumer with a third record of 0 requests/second (for example a suspended API Product Subscription) is unchanged, because the 0 adds nothing to the sum: + | + |**Aggregated limits**: 15 requests/second, 150 requests/minute + | + |A consumer whose only record is 0 requests/second: + | + |**Aggregated limits**: 0 requests/second (blocked, 429 on every call) + | |### Configuration | |Enable rate limiting by setting: @@ -341,7 +355,7 @@ object Glossary extends MdcLoggable { |``` |user_consumer_limit_anonymous_access=1000 |``` - |(Default: 1000 requests per hour) + |(Default: 1000 requests per hour. `0` blocks all anonymous access, `-1` removes the limit.) | |### Related Concepts | @@ -3086,6 +3100,26 @@ object Glossary extends MdcLoggable { | |There are over 13 endpoints for controlling Collections. |Some of these endpoints require Entitlements to Roles and some operate on your own personal collections such as your favourites. +| + """) + + glossaryItems += GlossaryItem( + title = "API Product Subscription", + description = s"""An API Product Subscription records that one Consumer (the subscriber) holds one API Product for a period, with a status. +| +|The API Product describes the plan: which endpoints (its API Collection), how many calls (six rate limits), the monthly price, and any attributes. The Subscription is the record of who holds it. Its status is what makes the product enforceable: +| +|- `requested`: created, nothing granted yet. +|- `active`: OBP-API has given the Consumer a rate limit record with the product's six limits, and a Scope for each Role required by the endpoints in the product's Collection. +|- `past_due`: payment is overdue. A grace period; nothing changes for the Consumer. +|- `suspended`: the subscription's rate limit record is set to `0` in every period, which blocks the Consumer's calls. Scopes are kept so reinstatement is cheap. +|- `cancelled`: the rate limit record and the derived Scopes are removed. Terminal; a new subscription is a new record. +| +|Only the rate limit record and the Scopes created by the subscription are touched. Limits and Scopes granted by hand are never removed. Overlapping rate limit records are summed, so a Consumer holding two products gets both allowances. +| +|A developer never needs a Role to subscribe their own Consumer, read their own subscriptions or cancel them. Roles exist for bank staff (enrol a partner's Consumer, approve, suspend, reinstate) and for billing systems (move the status on payment events). Two attributes on the API Product decide the flow: `SELF_SUBSCRIBE` (may developers subscribe their own Consumers; default `true`) and `BILLING_SYSTEM` (`none` activates at once; `manual` waits for a bank admin; `stripe` or `invoice_ninja` waits for that billing system). +| +|OBP-API core carries no billing vocabulary: payments, invoices and refunds live in the billing system, which only ever changes the subscription status. | """) diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index ba7209efd6..14d1b6e116 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -14,6 +14,8 @@ import code.api.{APIFailureNewStyle, Constant, JsonResponseException} import code.apicollection.{ApiCollectionTrait, MappedApiCollectionsProvider} import code.apiproduct.{ApiProductTrait, MappedApiProductsProvider} import code.apiproductattribute.{ApiProductAttributeTrait, MappedApiProductAttributesProvider} +import code.apiproductsubscription.{ApiProductSubscriptionEnforcer, ApiProductSubscriptionStatus, ApiProductSubscriptionTrait, MappedApiProductSubscriptionsProvider} +import code.apiproductsubscriptionattribute.{ApiProductSubscriptionAttributeTrait, MappedApiProductSubscriptionAttributesProvider} import code.apicollectionendpoint.{ApiCollectionEndpointTrait, MappedApiCollectionEndpointsProvider} import code.featuredapicollection.{FeaturedApiCollectionTrait, MappedFeaturedApiCollectionsProvider} import code.atmattribute.AtmAttribute @@ -4148,6 +4150,116 @@ object NewStyle extends MdcLoggable{ } } + // ─── API Product Subscriptions (see API_PRODUCT_SUBSCRIPTION_PLAN.md) ─── + + def createApiProductSubscription( + bankId: String, + apiProductCode: String, + consumerId: String, + status: String, + startDate: java.util.Date, + endDate: Option[java.util.Date], + createdByUserId: String, + callContext: Option[CallContext] + ): OBPReturnType[ApiProductSubscriptionTrait] = { + Future(MappedApiProductSubscriptionsProvider.createApiProductSubscription( + bankId, apiProductCode, consumerId, status, startDate, endDate, createdByUserId + )) map { + i => (unboxFullOrFail(i, callContext, CreateApiProductSubscriptionError), callContext) + } + } + + def getApiProductSubscriptionById(apiProductSubscriptionId: String, callContext: Option[CallContext]): OBPReturnType[ApiProductSubscriptionTrait] = { + Future(MappedApiProductSubscriptionsProvider.getApiProductSubscriptionById(apiProductSubscriptionId)) map { + i => (unboxFullOrFail(i, callContext, s"$ApiProductSubscriptionNotFound Current API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)", 404), callContext) + } + } + + def getApiProductSubscriptionsByConsumerId(consumerId: String, callContext: Option[CallContext]): OBPReturnType[List[ApiProductSubscriptionTrait]] = { + Future(MappedApiProductSubscriptionsProvider.getApiProductSubscriptionsByConsumerId(consumerId), callContext) + } + + def getApiProductSubscriptionsByConsumerIds(consumerIds: List[String], callContext: Option[CallContext]): OBPReturnType[List[ApiProductSubscriptionTrait]] = { + Future(MappedApiProductSubscriptionsProvider.getApiProductSubscriptionsByConsumerIds(consumerIds), callContext) + } + + def getApiProductSubscriptionsByBankIdAndProductCode(bankId: String, apiProductCode: String, callContext: Option[CallContext]): OBPReturnType[List[ApiProductSubscriptionTrait]] = { + Future(MappedApiProductSubscriptionsProvider.getApiProductSubscriptionsByBankIdAndProductCode(bankId, apiProductCode), callContext) + } + + def getNonCancelledApiProductSubscription(consumerId: String, bankId: String, apiProductCode: String, callContext: Option[CallContext]): Future[Box[ApiProductSubscriptionTrait]] = { + Future(MappedApiProductSubscriptionsProvider.getNonCancelledApiProductSubscription(consumerId, bankId, apiProductCode)) + } + + /** + * Validates the status value and the transition (ApiProductSubscriptionStatus), then moves it. + * Phase 3 of the plan hooks enforcement (rate limits, scopes) in here. + */ + def updateApiProductSubscriptionStatus(apiProductSubscriptionId: String, newStatus: String, endDate: Option[java.util.Date], callContext: Option[CallContext]): OBPReturnType[ApiProductSubscriptionTrait] = { + for { + (current, _) <- getApiProductSubscriptionById(apiProductSubscriptionId, callContext) + _ <- Helper.booleanToFuture(s"$InvalidApiProductSubscriptionStatus Current value: $newStatus", cc = callContext) { + ApiProductSubscriptionStatus.isValid(newStatus) + } + _ <- Helper.booleanToFuture( + s"$InvalidApiProductSubscriptionStatusTransition From ${current.status} to $newStatus. Allowed from ${current.status}: ${ApiProductSubscriptionStatus.allowedFrom(current.status).toList.sorted.mkString(", ")}.", + cc = callContext) { + ApiProductSubscriptionStatus.canTransition(current.status, newStatus) + } + updated <- Future(MappedApiProductSubscriptionsProvider.updateApiProductSubscriptionStatus(apiProductSubscriptionId, newStatus, endDate)) map { + unboxFullOrFail(_, callContext, UpdateApiProductSubscriptionError) + } + // Phase 3: apply rate limits and scopes for the new status; returns the refreshed subscription. + enforced <- ApiProductSubscriptionEnforcer.onStatusChanged(updated) + } yield (enforced, callContext) + } + + def deleteApiProductSubscription(apiProductSubscriptionId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { + Future(MappedApiProductSubscriptionsProvider.deleteApiProductSubscription(apiProductSubscriptionId)) map { + i => (unboxFullOrFail(i, callContext, s"$DeleteApiProductSubscriptionError Current API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)"), callContext) + } + } + + def getApiProductSubscriptionAttributes(apiProductSubscriptionId: String, callContext: Option[CallContext]): OBPReturnType[List[ApiProductSubscriptionAttributeTrait]] = { + Future(MappedApiProductSubscriptionAttributesProvider.getApiProductSubscriptionAttributes(apiProductSubscriptionId)) map { + i => (unboxFullOrFail(i, callContext, s"$ApiProductSubscriptionAttributeNotFound Current API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)"), callContext) + } + } + + def getApiProductSubscriptionAttributeById(apiProductSubscriptionAttributeId: String, callContext: Option[CallContext]): OBPReturnType[ApiProductSubscriptionAttributeTrait] = { + Future(MappedApiProductSubscriptionAttributesProvider.getApiProductSubscriptionAttributeById(apiProductSubscriptionAttributeId)) map { + i => (unboxFullOrFail(i, callContext, s"$ApiProductSubscriptionAttributeNotFound Current API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID($apiProductSubscriptionAttributeId)", 404), callContext) + } + } + + def createOrUpdateApiProductSubscriptionAttribute( + apiProductSubscriptionId: String, + apiProductSubscriptionAttributeId: Option[String], + name: String, + attributeType: String, + value: String, + isActive: Option[Boolean], + callContext: Option[CallContext] + ): OBPReturnType[ApiProductSubscriptionAttributeTrait] = { + Future(MappedApiProductSubscriptionAttributesProvider.createOrUpdateApiProductSubscriptionAttribute( + apiProductSubscriptionId, apiProductSubscriptionAttributeId, name, attributeType, value, isActive + )) map { + i => (unboxFullOrFail(i, callContext, CreateApiProductSubscriptionAttributeError), callContext) + } + } + + def deleteApiProductSubscriptionAttribute(apiProductSubscriptionAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { + Future(MappedApiProductSubscriptionAttributesProvider.deleteApiProductSubscriptionAttribute(apiProductSubscriptionAttributeId)) map { + i => (unboxFullOrFail(i, callContext, s"$DeleteApiProductSubscriptionAttributeError Current API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID($apiProductSubscriptionAttributeId)"), callContext) + } + } + + def deleteApiProductSubscriptionAttributes(apiProductSubscriptionId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { + Future(MappedApiProductSubscriptionAttributesProvider.deleteApiProductSubscriptionAttributes(apiProductSubscriptionId)) map { + i => (unboxFullOrFail(i, callContext, s"$DeleteApiProductSubscriptionAttributeError Current API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)"), callContext) + } + } + def deleteApiProductAttributesByBankIdAndCode(bankId: String, apiProductCode: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { Future(MappedApiProductAttributesProvider.deleteApiProductAttributesByBankIdAndCode(bankId, apiProductCode)) map { i => (unboxFullOrFail(i, callContext, s"$DeleteApiProductAttributeError Current BANK_ID($bankId) API_PRODUCT_CODE($apiProductCode)"), callContext) diff --git a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala index 05566913cc..3a02b99384 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -107,9 +107,16 @@ object RateLimitingUtil extends MdcLoggable { } def aggregateRateLimits(rateLimitRecords: List[RateLimiting]): CallLimit = { - def sumLimits(values: List[Long]): Long = { - val positiveValues = values.filter(_ > 0) - if (positiveValues.isEmpty) -1 else positiveValues.sum + // Per period, over the consumer's active rows (see Glossary "Rate Limiting"): + // -1 values are ignored (unlimited rows contribute nothing) + // the remaining values (>= 0) are summed: overlapping rows add up, by design + // a sum of 0 means blocked: every call in that period is refused with 429. A single 0 row does + // not override positive rows; it only blocks when nothing else grants calls. + // nothing to sum (all -1) -> -1 unlimited. A row exists, so the rate_limiting_per_* props do not apply. + // No rows at all is handled below: the rate_limiting_per_* props apply. + def resolveLimit(values: List[Long]): Long = { + val counted = values.filter(_ >= 0) + if (counted.isEmpty) -1 else counted.sum } if (rateLimitRecords.nonEmpty) { @@ -118,12 +125,12 @@ object RateLimitingUtil extends MdcLoggable { rateLimitRecords.find(_.apiName.isDefined).flatMap(_.apiName), rateLimitRecords.find(_.apiVersion.isDefined).flatMap(_.apiVersion), rateLimitRecords.find(_.bankId.isDefined).flatMap(_.bankId), - sumLimits(rateLimitRecords.map(_.perSecondCallLimit)), - sumLimits(rateLimitRecords.map(_.perMinuteCallLimit)), - sumLimits(rateLimitRecords.map(_.perHourCallLimit)), - sumLimits(rateLimitRecords.map(_.perDayCallLimit)), - sumLimits(rateLimitRecords.map(_.perWeekCallLimit)), - sumLimits(rateLimitRecords.map(_.perMonthCallLimit)) + resolveLimit(rateLimitRecords.map(_.perSecondCallLimit)), + resolveLimit(rateLimitRecords.map(_.perMinuteCallLimit)), + resolveLimit(rateLimitRecords.map(_.perHourCallLimit)), + resolveLimit(rateLimitRecords.map(_.perDayCallLimit)), + resolveLimit(rateLimitRecords.map(_.perWeekCallLimit)), + resolveLimit(rateLimitRecords.map(_.perMonthCallLimit)) ) } else { // No records found - return system defaults @@ -217,9 +224,11 @@ object RateLimitingUtil extends MdcLoggable { logger.warn(s"Unknown status '${state.status}' when checking rate limit for consumer $consumerKey, period $period - allowing request") true } + case 0 => + // A limit of 0 means blocked: refuse every call for this period, without touching Redis + false case _ => - // Rate Limiting for a Consumer <= 0 implies successful result - // Or any other unhandled case implies successful result + // A negative limit (-1) means unlimited for this period true } } else { @@ -335,7 +344,7 @@ object RateLimitingUtil extends MdcLoggable { * ERROR HANDLING: * - Redis connectivity issues default to allowing the request (fail-open) * - Rate limiting can be globally disabled via "use_consumer_limits" property - * - Malformed or missing limits default to unlimited access + * - A limit of 0 blocks the period (429 on every call), -1 means unlimited, no records means the props defaults apply * * @param userAndCallContext Tuple containing (Box[User], Option[CallContext]) from authentication * @return Same tuple structure, either with updated rate limit headers or rate limit exceeded error @@ -343,8 +352,13 @@ object RateLimitingUtil extends MdcLoggable { def underCallLimits(userAndCallContext: (Box[User], Option[CallContext])): (Box[User], Option[CallContext]) = { // Configuration and helper functions def perHourLimitAnonymous = APIUtil.getPropsAsIntValue("user_consumer_limit_anonymous_access", 1000) - def composeMsgAuthorizedAccess(period: LimitCallPeriod, limit: Long, consumerId: String): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for this Consumer (consumer_id: $consumerId)." - def composeMsgAnonymousAccess(period: LimitCallPeriod, limit: Long): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for anonymous access." + def composeMsgBlocked(period: LimitCallPeriod, consumerId: String): String = TooManyRequests + s" This Consumer is blocked: its active rate limit ${RateLimitingPeriod.humanReadable(period)} is 0 (consumer_id: $consumerId)." + def composeMsgAuthorizedAccess(period: LimitCallPeriod, limit: Long, consumerId: String): String = + if (limit == 0) composeMsgBlocked(period, consumerId) + else TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for this Consumer (consumer_id: $consumerId)." + def composeMsgAnonymousAccess(period: LimitCallPeriod, limit: Long): String = + if (limit == 0) TooManyRequests + s" Anonymous access is blocked: the anonymous rate limit ${RateLimitingPeriod.humanReadable(period)} is 0." + else TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for anonymous access." // Helper function to set rate limit headers in successful responses def setXRateLimits(c: CallLimit, z: (Long, Long), period: LimitCallPeriod): Option[CallContext] = { 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 88d47a4e8d..d55caeb6f2 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 @@ -6189,6 +6189,8 @@ object Http4s600 { } + + // ─── Phase 2: mandates bucket (10 endpoints) ────────────────────────── // Parse `yyyy-MM-dd'T'HH:mm:ss'Z'` UTC strings; v6 Lift's exact format. @@ -14355,6 +14357,19 @@ object Http4s600 { s""" |Create Rate Limits for a Consumer | + |Each of the six limits is one of: + | + |* `0`: this record grants no calls in that period. Records are summed, so the consumer is blocked (every call refused with 429) only when the sum over all of its records for that period is 0. + |* `-1`: unlimited for that period, adding nothing to the sum. Once a record exists, the system default for that period no longer applies. + |* a positive number: the maximum number of calls in that period. Overlapping records for the consumer are summed. + | + |A consumer with no records at all gets the system defaults (`rate_limiting_per_*` props). + | + |A record created by an API Product Subscription is managed by that subscription: it is rewritten on the + |subscription's next status change and removed when the subscription is cancelled. + | + |See ${Glossary.getGlossaryItemLink("Rate Limiting")} for details. + | |${userAuthenticationMessage(true)} | |""".stripMargin, @@ -14386,9 +14401,22 @@ object Http4s600 { |Per Second |Per Minute |Per Hour + |Per Day |Per Week |Per Month | + |Each of the six limits is one of: + | + |* `0`: this record grants no calls in that period. Records are summed, so the consumer is blocked (every call refused with 429) only when the sum over all of its records for that period is 0. + |* `-1`: unlimited for that period, adding nothing to the sum. Once a record exists, the system default for that period no longer applies. + |* a positive number: the maximum number of calls in that period. Overlapping records for the consumer are summed. + | + |A consumer with no records at all gets the system defaults (`rate_limiting_per_*` props). + | + |A record created by an API Product Subscription is managed by that subscription: it is rewritten on the + |subscription's next status change and removed when the subscription is cancelled. + | + |See ${Glossary.getGlossaryItemLink("Rate Limiting")} for details. | |${userAuthenticationMessage(true)} | @@ -14417,6 +14445,8 @@ object Http4s600 { s""" |Delete a specific Rate Limit by Rate Limiting ID | + |A record created by an API Product Subscription will be recreated on the subscription's next status change; cancel the subscription instead. + | |${userAuthenticationMessage(true)} | |""".stripMargin, @@ -14442,6 +14472,8 @@ object Http4s600 { s""" |Get the active rate limits for a consumer at the current date/time. Returns the aggregated rate limits from all active records at this moment. | + |A value of `0` means the consumer is blocked for that period, `-1` means unlimited, and a consumer with no records shows the system defaults. + | |This is a convenience endpoint that uses the current date/time automatically. | |See ${Glossary.getGlossaryItemLink("Rate Limiting")} for more details on how rate limiting works. @@ -14471,6 +14503,8 @@ object Http4s600 { s""" |Get the active rate limits for a consumer for a specific hour. Returns the aggregated rate limits from all active records during that hour. | + |A value of `0` means the consumer is blocked for that period, `-1` means unlimited, and a consumer with no records shows the system defaults. + | |Rate limits are cached and queried at hour-level granularity. | |See ${Glossary.getGlossaryItemLink("Rate Limiting")} for more details on how rate limiting works. @@ -14598,6 +14632,20 @@ object Http4s600 { "Create Api Product", s"""Create an Api Product for the Bank. | + |An Api Product describes a plan: which endpoints (collection_id), how many calls (the six call limits), the price (monthly_subscription_amount), tiers (parent_api_product_code) and anything else (attributes). + | + |Call limits: `-1` means unlimited for that period once a consumer subscribes (it is copied literally to the consumer's rate limit record; it does not mean "inherit the system default"). `0` means blocked. A positive number is the maximum number of calls in that period. + | + |Recognised attribute names (set with the Api Product Attribute endpoints): + | + |* `SELF_SUBSCRIBE`: `true` (default) or `false`. Whether developers may subscribe their own consumers, or only the bank may enrol them. + |* `BILLING_SYSTEM`: `none` (default), `manual`, `stripe` or `invoice_ninja`. Which system moves a subscription from requested to active. + |* `INCLUDED_CALLS_PER_MONTH`: calls included in the monthly price. + |* `OVERAGE_PRICE_PER_CALL`: price per call above the included calls. + |* `TRIAL_DAYS`: free trial length in days. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")} for how subscriptions use these. + | |Authentication is Required. | |""".stripMargin, 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 d2ce739adb..517c4b148a 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 @@ -23,6 +23,8 @@ import code.api.v2_0_0.{BasicViewJson, CreateEntitlementJSON, JSONFactory200} import code.api.v4_0_0.JSONFactory400 import code.api.v6_0_0.{BasicAccountJsonV600, BasicAccountsJsonV600, BankJsonV600, BanksJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheNamespaceJsonV600, CacheNamespacesJsonV600, ConnectorInfoJsonV600, ConnectorsJsonV600, DatabasePoolInfoJsonV600, FeaturesJsonV600, InMemoryCacheStatusJsonV600, JSONFactory600, RedisCacheStatusJsonV600, StoredProcedureConnectorHealthJsonV600, UserV600} import code.api.v6_0_0.JSONFactory600.ViewJsonV600 +import code.api.v7_0_0.JSONFactory700.{ApiProductSubscriptionAttributeJsonV700, ApiProductSubscriptionJsonV700, ApiProductSubscriptionsJsonV700, PostApiProductSubscriptionJsonV700, PutApiProductSubscriptionStatusJsonV700} +import code.apiproductsubscription.{ApiProductSubscriptionStatus, ApiProductSubscriptionTrait} import code.api.cache.Redis import code.bankconnectors.storedprocedure.StoredProcedureUtils import code.migration.MigrationScriptLogProvider @@ -5359,6 +5361,559 @@ object Http4s700 { http4sPartialFunction = Some(getDynamicMessageDocProvenance) ) + + // ─── API Product Subscriptions (see API_PRODUCT_SUBSCRIPTION_PLAN.md) ────────────────── + // Rule zero: a developer never needs a role for their own consumers; ownership + // (Consumer.createdByUserId == caller) is checked here. Roles are checked at the PRODUCT's + // bank (…AtOneBank); a billing adapter serving several banks is granted the role at each. + // Docs for the management endpoints declare their roles for the catalog but disable auto + // validation, because the bank is the subscription's bank, not a BANK_ID in the path. + // The API Product endpoints these extend are v6.0.0; new endpoints go in v7.0.0. + + private def apiProductAttributeValue(attributes: List[code.apiproductattribute.ApiProductAttributeTrait], name: String): Option[String] = + attributes.find(a => a.name.equalsIgnoreCase(name) && a.isActive.getOrElse(true)).map(_.value.trim.toLowerCase) + + private def userOwnsConsumer(consumer: code.model.Consumer, userId: String): Boolean = + Option(consumer.createdByUserId.get).exists(_ == userId) + + private def userOwnsSubscription(subscription: ApiProductSubscriptionTrait, userId: String): Future[Boolean] = + code.consumer.Consumers.consumers.vend.getConsumerByConsumerIdFuture(subscription.consumerId) + .map(_.exists(c => userOwnsConsumer(c, userId))) + + private def subscriptionRoleCheck(bankId: String, userId: String, role: ApiRole, cc: CallContext): Future[net.liftweb.common.Box[Unit]] = + NewStyle.function.handleEntitlementsAndScopes(bankId, userId, role :: Nil, Some(cc)) + + private def subscriptionWithAttributesJson(subscription: ApiProductSubscriptionTrait, cc: CallContext): Future[ApiProductSubscriptionJsonV700] = + NewStyle.function.getApiProductSubscriptionAttributes(subscription.apiProductSubscriptionId, Some(cc)) + .map { case (attributes, _) => JSONFactory700.createApiProductSubscriptionJsonV700(subscription, Some(attributes)) } + + private def subscriptionsWithAttributesJson(subscriptions: List[ApiProductSubscriptionTrait], cc: CallContext): Future[ApiProductSubscriptionsJsonV700] = + Future.sequence(subscriptions.map(subscriptionWithAttributesJson(_, cc))) + .map(JSONFactory700.createApiProductSubscriptionsJsonV700) + + // Route: POST /obp/v7.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE/subscriptions (201) + val createApiProductSubscription: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode / "subscriptions" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostApiProductSubscriptionJsonV700", 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[PostApiProductSubscriptionJsonV700] + } + consumerId = Option(postJson.consumer_id).map(_.trim).getOrElse("") + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat consumer_id is required: the Consumer to subscribe, never the calling Consumer.", cc = Some(cc)) { + consumerId.nonEmpty + } + (product, _) <- NewStyle.function.getApiProductByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + (attributes, _) <- NewStyle.function.getApiProductAttributesByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + selfSubscribe = !apiProductAttributeValue(attributes, "SELF_SUBSCRIBE").contains("false") + billingSystem = apiProductAttributeValue(attributes, "BILLING_SYSTEM").filter(_.nonEmpty).getOrElse("none") + // No role needed when the product is open to self-service AND the caller owns the consumer. + _ <- if (selfSubscribe && userOwnsConsumer(consumer, user.userId)) Future.successful(Full(())) + else subscriptionRoleCheck(product.bankId, user.userId, ApiRole.canCreateApiProductSubscriptionAtOneBank, cc) + existing <- NewStyle.function.getNonCancelledApiProductSubscription(consumer.consumerId.get, product.bankId, product.apiProductCode, Some(cc)) + _ <- Helper.booleanToFuture(ApiProductSubscriptionAlreadyExists, 409, Some(cc)) { existing.isEmpty } + (created, _) <- NewStyle.function.createApiProductSubscription( + product.bankId, product.apiProductCode, consumer.consumerId.get, ApiProductSubscriptionStatus.Requested, + postJson.start_date.getOrElse(new java.util.Date()), postJson.end_date, user.userId, Some(cc)) + // BILLING_SYSTEM none / absent: nobody needs to approve or pay, so it is active at once. + (subscription, _) <- if (billingSystem == "none") + NewStyle.function.updateApiProductSubscriptionStatus(created.apiProductSubscriptionId, ApiProductSubscriptionStatus.Active, None, Some(cc)) + else Future.successful((created, Some(cc))) + } yield JSONFactory700.createApiProductSubscriptionJsonV700(subscription, None) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createApiProductSubscription), + "POST", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE/subscriptions", + "Create Api Product Subscription", + s"""Subscribe a Consumer to an Api Product. + | + |The body names the Consumer to subscribe (`consumer_id`); it is never the calling Consumer. A developer + |may subscribe a Consumer they created (Consumer.created_by_user_id is the caller) without any Role, as + |long as the product's `SELF_SUBSCRIBE` attribute is not `false`. Otherwise one of the roles below is + |required at the product's bank, which is how a bank enrols a + |partner's Consumer itself. + | + |The subscription is created with status `requested`. If the product's `BILLING_SYSTEM` attribute is + |`none` or absent it becomes `active` at once; `manual` waits for a bank admin; `stripe` / `invoice_ninja` + |wait for that billing system to PUT the status. + | + |Refused with 409 if the Consumer already holds a non-cancelled subscription to this product. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + JSONFactory700.postApiProductSubscriptionJsonV700Example, + JSONFactory700.apiProductSubscriptionJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ApiProductNotFound, ConsumerNotFoundByConsumerId, ApiProductSubscriptionAlreadyExists, CreateApiProductSubscriptionError, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canCreateApiProductSubscriptionAtOneBank)), + http4sPartialFunction = Some(createApiProductSubscription) + ).disableAutoValidateRoles() + + // Route: GET /obp/v7.0.0/my/api-product-subscriptions + val getMyApiProductSubscriptions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-product-subscriptions" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + consumers <- code.consumer.Consumers.consumers.vend.getConsumersByUserIdFuture(user.userId) + (subscriptions, _) <- NewStyle.function.getApiProductSubscriptionsByConsumerIds(consumers.map(_.consumerId.get), Some(cc)) + json <- subscriptionsWithAttributesJson(subscriptions, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getMyApiProductSubscriptions), + "GET", + "/my/api-product-subscriptions", + "Get My Api Product Subscriptions", + s"""Get the Api Product Subscriptions of every Consumer the current User created, with their attributes. + | + |No Role is required. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.apiProductSubscriptionsJsonV700Example, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + None, + http4sPartialFunction = Some(getMyApiProductSubscriptions) + ) + + // Route: GET /obp/v7.0.0/my/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID + val getMyApiProductSubscription: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-product-subscriptions" / apiProductSubscriptionId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + owned <- userOwnsSubscription(subscription, user.userId) + // 404, not 403: do not reveal that someone else's subscription exists. + _ <- Helper.booleanToFuture(s"$ApiProductSubscriptionNotFound Current API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)", 404, Some(cc)) { owned } + json <- subscriptionWithAttributesJson(subscription, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getMyApiProductSubscription), + "GET", + "/my/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID", + "Get My Api Product Subscription", + s"""Get one Api Product Subscription of a Consumer the current User created, with its attributes. + | + |No Role is required. A subscription of a Consumer the User did not create is reported as not found. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.apiProductSubscriptionJsonV700Example, + List($AuthenticatedUserIsRequired, ApiProductSubscriptionNotFound, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + None, + http4sPartialFunction = Some(getMyApiProductSubscription) + ) + + // Route: PUT /obp/v7.0.0/my/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/status + val updateMyApiProductSubscriptionStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "api-product-subscriptions" / apiProductSubscriptionId / "status" => + EndpointHelpers.withUser(req) { (user, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PutApiProductSubscriptionStatusJsonV700", 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[PutApiProductSubscriptionStatusJsonV700] + } + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + owned <- userOwnsSubscription(subscription, user.userId) + _ <- Helper.booleanToFuture(ConsumerNotOwnedByUser, 403, Some(cc)) { owned } + _ <- Helper.booleanToFuture(s"$InvalidApiProductSubscriptionStatusTransition A developer may only set the status to ${ApiProductSubscriptionStatus.Cancelled}.", cc = Some(cc)) { + putJson.status == ApiProductSubscriptionStatus.Cancelled + } + (updated, _) <- NewStyle.function.updateApiProductSubscriptionStatus(apiProductSubscriptionId, ApiProductSubscriptionStatus.Cancelled, putJson.end_date, Some(cc)) + json <- subscriptionWithAttributesJson(updated, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(updateMyApiProductSubscriptionStatus), + "PUT", + "/my/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/status", + "Cancel My Api Product Subscription", + s"""Cancel an Api Product Subscription of a Consumer the current User created. + | + |No Role is required. The only status a developer may set is `cancelled`; any other value is refused. + |`cancelled` is terminal: to subscribe again, create a new subscription. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + JSONFactory700.putApiProductSubscriptionStatusJsonV700Example, + JSONFactory700.apiProductSubscriptionJsonV700Example, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, ApiProductSubscriptionNotFound, ConsumerNotOwnedByUser, InvalidApiProductSubscriptionStatusTransition, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + None, + http4sPartialFunction = Some(updateMyApiProductSubscriptionStatus) + ) + + // Route: GET /obp/v7.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE/subscriptions + val getApiProductSubscriptionsByProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode / "subscriptions" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (product, _) <- NewStyle.function.getApiProductByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + (subscriptions, _) <- NewStyle.function.getApiProductSubscriptionsByBankIdAndProductCode(product.bankId, product.apiProductCode, Some(cc)) + json <- subscriptionsWithAttributesJson(subscriptions, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getApiProductSubscriptionsByProduct), + "GET", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE/subscriptions", + "Get Api Product Subscriptions by Product", + s"""Get every Api Product Subscription to this Api Product (the subscribers), with attributes. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.apiProductSubscriptionsJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ApiProductNotFound, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canGetApiProductSubscriptionAtOneBank)), + http4sPartialFunction = Some(getApiProductSubscriptionsByProduct) + ) + + // Route: GET /obp/v7.0.0/management/consumers/CONSUMER_ID/api-product-subscriptions + val getConsumerApiProductSubscriptions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId / "api-product-subscriptions" => + EndpointHelpers.withUser(req) { (user, cc) => + val role = ApiRole.canGetApiProductSubscriptionAtOneBank + for { + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + owner = userOwnsConsumer(consumer, user.userId) + (subscriptions, _) <- NewStyle.function.getApiProductSubscriptionsByConsumerId(consumer.consumerId.get, Some(cc)) + // The owner sees every subscription. Anyone else sees those at the banks where they hold the + // role, and is refused outright when they hold it nowhere. + visible <- if (owner) Future.successful(subscriptions) + else Future { + val consumerPk = APIUtil.getConsumerPrimaryKey(Some(cc)) + val allowedBanks = subscriptions.map(_.bankId).distinct + .filter(bankId => APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, user.userId, consumerPk, role :: Nil)) + .toSet + subscriptions.filter(s => allowedBanks.contains(s.bankId)) + } + roleSomewhere <- if (owner || visible.nonEmpty) Future.successful(true) + else Entitlement.entitlement.vend.getEntitlementsByUserIdFuture(user.userId) + .map(_.map(_.exists(_.roleName == role.toString)).getOrElse(false)) + _ <- Helper.booleanToFuture(s"$UserHasMissingRoles$role at a bank of the Consumer's subscriptions, unless you created the Consumer.", 403, Some(cc)) { + roleSomewhere + } + json <- subscriptionsWithAttributesJson(visible, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getConsumerApiProductSubscriptions), + "GET", + "/management/consumers/CONSUMER_ID/api-product-subscriptions", + "Get Api Product Subscriptions by Consumer", + s"""Get every Api Product Subscription held by a Consumer, at any bank, with attributes. + | + |A Consumer is not bank-scoped. The caller who created the Consumer sees all of its subscriptions; + |anyone else sees the subscriptions at the banks where they hold the role, and gets 403 if they + |hold it at none of them. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.apiProductSubscriptionsJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConsumerNotFoundByConsumerId, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canGetApiProductSubscriptionAtOneBank)), + http4sPartialFunction = Some(getConsumerApiProductSubscriptions) + ).disableAutoValidateRoles() + + // Route: GET /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID + val getApiProductSubscription: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canGetApiProductSubscriptionAtOneBank, cc) + json <- subscriptionWithAttributesJson(subscription, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getApiProductSubscription), + "GET", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID", + "Get Api Product Subscription", + s"""Get an Api Product Subscription by API_PRODUCT_SUBSCRIPTION_ID, with attributes. + | + |The role is checked at the subscription's bank. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.apiProductSubscriptionJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ApiProductSubscriptionNotFound, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canGetApiProductSubscriptionAtOneBank)), + http4sPartialFunction = Some(getApiProductSubscription) + ).disableAutoValidateRoles() + + // Route: PUT /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/status + // The one write a billing adapter makes. + val updateApiProductSubscriptionStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId / "status" => + EndpointHelpers.withUser(req) { (user, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PutApiProductSubscriptionStatusJsonV700", 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[PutApiProductSubscriptionStatusJsonV700] + } + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canUpdateApiProductSubscriptionStatusAtOneBank, cc) + (updated, _) <- NewStyle.function.updateApiProductSubscriptionStatus(apiProductSubscriptionId, putJson.status, putJson.end_date, Some(cc)) + json <- subscriptionWithAttributesJson(updated, cc) + } yield json + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(updateApiProductSubscriptionStatus), + "PUT", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/status", + "Update Api Product Subscription Status", + s"""Move an Api Product Subscription to a new status. This is the one write a billing system makes. + | + |Allowed transitions: `requested` to `active` or `cancelled`; `active` to `past_due`, `suspended` or `cancelled`; + |`past_due` to `active`, `suspended` or `cancelled`; `suspended` to `active` or `cancelled`. `cancelled` is terminal. + |`end_date`, when given, replaces the stored end date. + | + |The role is checked at the subscription's bank. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + JSONFactory700.putApiProductSubscriptionStatusJsonV700Example, + JSONFactory700.apiProductSubscriptionJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ApiProductSubscriptionNotFound, InvalidApiProductSubscriptionStatus, InvalidApiProductSubscriptionStatusTransition, UpdateApiProductSubscriptionError, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canUpdateApiProductSubscriptionStatusAtOneBank)), + http4sPartialFunction = Some(updateApiProductSubscriptionStatus) + ).disableAutoValidateRoles() + + // Route: DELETE /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID + val deleteApiProductSubscription: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canDeleteApiProductSubscriptionAtOneBank, cc) + // A live subscription is cancelled first so that Phase 3 enforcement releases what it granted. + _ <- if (subscription.status == ApiProductSubscriptionStatus.Cancelled) Future.successful(()) + else NewStyle.function.updateApiProductSubscriptionStatus(apiProductSubscriptionId, ApiProductSubscriptionStatus.Cancelled, None, Some(cc)) + _ <- NewStyle.function.deleteApiProductSubscriptionAttributes(apiProductSubscriptionId, Some(cc)) + _ <- Future(code.apiproductsubscription.MappedApiProductSubscriptionScopesProvider.deleteScopeRecords(apiProductSubscriptionId)) + _ <- NewStyle.function.deleteApiProductSubscription(apiProductSubscriptionId, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteApiProductSubscription), + "DELETE", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID", + "Delete Api Product Subscription", + s"""Delete an Api Product Subscription and its attributes. A live subscription is cancelled first, so anything + |it granted to the Consumer is released. Prefer cancelling over deleting: a cancelled subscription is history. + | + |The role is checked at the subscription's bank. + | + |See ${Glossary.getGlossaryItemLink("API Product Subscription")}. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ApiProductSubscriptionNotFound, DeleteApiProductSubscriptionError, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canDeleteApiProductSubscriptionAtOneBank)), + http4sPartialFunction = Some(deleteApiProductSubscription) + ).disableAutoValidateRoles() + + // Route: POST /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attribute (201) + val createApiProductSubscriptionAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId / "attribute" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canCreateApiProductSubscriptionAttributeAtOneBank, cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ApiProductSubscriptionAttributeJsonV700", 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[ApiProductSubscriptionAttributeJsonV700] + } + (attribute, _) <- NewStyle.function.createOrUpdateApiProductSubscriptionAttribute( + subscription.apiProductSubscriptionId, None, postJson.name, postJson.`type`, postJson.value, postJson.is_active, Some(cc)) + } yield JSONFactory700.createApiProductSubscriptionAttributeResponseJsonV700(attribute) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createApiProductSubscriptionAttribute), + "POST", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attribute", + "Create Api Product Subscription Attribute", + s"""Create an attribute on an Api Product Subscription. Billing systems store their own identifiers here, + |for example `STRIPE_SUBSCRIPTION_ID`. + | + |The role is checked at the subscription's bank. + | + |${userAuthenticationMessage(true)}""".stripMargin, + JSONFactory700.apiProductSubscriptionAttributeJsonV700Example, + JSONFactory700.apiProductSubscriptionAttributeResponseJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ApiProductSubscriptionNotFound, CreateApiProductSubscriptionAttributeError, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canCreateApiProductSubscriptionAttributeAtOneBank)), + http4sPartialFunction = Some(createApiProductSubscriptionAttribute) + ).disableAutoValidateRoles() + + // Route: PUT /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attributes/API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID + val updateApiProductSubscriptionAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId / "attributes" / attributeId => + EndpointHelpers.withUser(req) { (user, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canUpdateApiProductSubscriptionAttributeAtOneBank, cc) + (existing, _) <- NewStyle.function.getApiProductSubscriptionAttributeById(attributeId, Some(cc)) + _ <- Helper.booleanToFuture(s"$ApiProductSubscriptionAttributeNotFound The attribute does not belong to API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)", 404, Some(cc)) { + existing.apiProductSubscriptionId == subscription.apiProductSubscriptionId + } + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ApiProductSubscriptionAttributeJsonV700", 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[ApiProductSubscriptionAttributeJsonV700] + } + (attribute, _) <- NewStyle.function.createOrUpdateApiProductSubscriptionAttribute( + subscription.apiProductSubscriptionId, Some(attributeId), putJson.name, putJson.`type`, putJson.value, putJson.is_active, Some(cc)) + } yield JSONFactory700.createApiProductSubscriptionAttributeResponseJsonV700(attribute) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(updateApiProductSubscriptionAttribute), + "PUT", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attributes/API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID", + "Update Api Product Subscription Attribute", + s"""Update an attribute of an Api Product Subscription. + | + |The role is checked at the subscription's bank. + | + |${userAuthenticationMessage(true)}""".stripMargin, + JSONFactory700.apiProductSubscriptionAttributeJsonV700Example, + JSONFactory700.apiProductSubscriptionAttributeResponseJsonV700Example, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ApiProductSubscriptionNotFound, ApiProductSubscriptionAttributeNotFound, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canUpdateApiProductSubscriptionAttributeAtOneBank)), + http4sPartialFunction = Some(updateApiProductSubscriptionAttribute) + ).disableAutoValidateRoles() + + // Route: GET /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attributes + val getApiProductSubscriptionAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId / "attributes" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canGetApiProductSubscriptionAtOneBank, cc) + (attributes, _) <- NewStyle.function.getApiProductSubscriptionAttributes(subscription.apiProductSubscriptionId, Some(cc)) + } yield attributes.map(JSONFactory700.createApiProductSubscriptionAttributeResponseJsonV700) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getApiProductSubscriptionAttributes), + "GET", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attributes", + "Get Api Product Subscription Attributes", + s"""Get the attributes of an Api Product Subscription. + | + |The role is checked at the subscription's bank. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + List(JSONFactory700.apiProductSubscriptionAttributeResponseJsonV700Example), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ApiProductSubscriptionNotFound, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canGetApiProductSubscriptionAtOneBank)), + http4sPartialFunction = Some(getApiProductSubscriptionAttributes) + ).disableAutoValidateRoles() + + // Route: DELETE /obp/v7.0.0/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attributes/API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID + val deleteApiProductSubscriptionAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "api-product-subscriptions" / apiProductSubscriptionId / "attributes" / attributeId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (subscription, _) <- NewStyle.function.getApiProductSubscriptionById(apiProductSubscriptionId, Some(cc)) + _ <- subscriptionRoleCheck(subscription.bankId, user.userId, ApiRole.canDeleteApiProductSubscriptionAttributeAtOneBank, cc) + (existing, _) <- NewStyle.function.getApiProductSubscriptionAttributeById(attributeId, Some(cc)) + _ <- Helper.booleanToFuture(s"$ApiProductSubscriptionAttributeNotFound The attribute does not belong to API_PRODUCT_SUBSCRIPTION_ID($apiProductSubscriptionId)", 404, Some(cc)) { + existing.apiProductSubscriptionId == subscription.apiProductSubscriptionId + } + _ <- NewStyle.function.deleteApiProductSubscriptionAttribute(attributeId, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteApiProductSubscriptionAttribute), + "DELETE", + "/management/api-product-subscriptions/API_PRODUCT_SUBSCRIPTION_ID/attributes/API_PRODUCT_SUBSCRIPTION_ATTRIBUTE_ID", + "Delete Api Product Subscription Attribute", + s"""Delete an attribute of an Api Product Subscription. + | + |The role is checked at the subscription's bank. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ApiProductSubscriptionNotFound, ApiProductSubscriptionAttributeNotFound, DeleteApiProductSubscriptionAttributeError, UnknownError), + apiTagApi :: apiTagApiProductSubscription :: Nil, + Some(List(ApiRole.canDeleteApiProductSubscriptionAttributeAtOneBank)), + http4sPartialFunction = Some(deleteApiProductSubscriptionAttribute) + ).disableAutoValidateRoles() + // All routes combined (without middleware - for direct use). // // Routes are sorted automatically by URL template specificity (segment count, 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 e4da06be41..0700b61261 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 @@ -8,6 +8,8 @@ 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.apiproductsubscription.ApiProductSubscriptionTrait +import code.apiproductsubscriptionattribute.ApiProductSubscriptionAttributeTrait import code.bankconnectors.Connector import code.customer.CustomerX import code.metrics.{MappedMetric, MetricArchive, MetricsArchiveRun, MetricsProps} @@ -2194,4 +2196,110 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ), everything_as_expected = true ) + + // ─── API Product Subscription (v7.0.0). See API_PRODUCT_SUBSCRIPTION_PLAN.md ─────────────── + + case class PostApiProductSubscriptionJsonV700( + consumer_id: String, + start_date: Option[Date], + end_date: Option[Date] + ) + + case class PutApiProductSubscriptionStatusJsonV700( + status: String, + end_date: Option[Date] + ) + + case class ApiProductSubscriptionAttributeJsonV700( + name: String, + `type`: String, + value: String, + is_active: Option[Boolean] + ) + + case class ApiProductSubscriptionAttributeResponseJsonV700( + api_product_subscription_id: String, + api_product_subscription_attribute_id: String, + name: String, + `type`: String, + value: String, + is_active: Option[Boolean] + ) + + case class ApiProductSubscriptionJsonV700( + api_product_subscription_id: String, + bank_id: String, + api_product_code: String, + consumer_id: String, + status: String, + start_date: Date, + end_date: Option[Date], + created_by_user_id: String, + rate_limiting_id: Option[String], + created_at: Date, + updated_at: Date, + attributes: Option[List[ApiProductSubscriptionAttributeResponseJsonV700]] + ) + + case class ApiProductSubscriptionsJsonV700(api_product_subscriptions: List[ApiProductSubscriptionJsonV700]) + + def createApiProductSubscriptionAttributeResponseJsonV700(attribute: ApiProductSubscriptionAttributeTrait): ApiProductSubscriptionAttributeResponseJsonV700 = + ApiProductSubscriptionAttributeResponseJsonV700( + api_product_subscription_id = attribute.apiProductSubscriptionId, + api_product_subscription_attribute_id = attribute.apiProductSubscriptionAttributeId, + name = attribute.name, + `type` = attribute.attributeType, + value = attribute.value, + is_active = attribute.isActive + ) + + def createApiProductSubscriptionJsonV700(subscription: ApiProductSubscriptionTrait, attributes: Option[List[ApiProductSubscriptionAttributeTrait]]): ApiProductSubscriptionJsonV700 = + ApiProductSubscriptionJsonV700( + api_product_subscription_id = subscription.apiProductSubscriptionId, + bank_id = subscription.bankId, + api_product_code = subscription.apiProductCode, + consumer_id = subscription.consumerId, + status = subscription.status, + start_date = subscription.startDate, + end_date = subscription.endDate, + created_by_user_id = subscription.createdByUserId, + rate_limiting_id = subscription.rateLimitingId, + created_at = subscription.createdAtDate, + updated_at = subscription.updatedAtDate, + attributes = attributes.map(_.map(createApiProductSubscriptionAttributeResponseJsonV700)) + ) + + def createApiProductSubscriptionsJsonV700(subscriptions: List[ApiProductSubscriptionJsonV700]): ApiProductSubscriptionsJsonV700 = + ApiProductSubscriptionsJsonV700(subscriptions) + + // Examples for the resource docs. + lazy val postApiProductSubscriptionJsonV700Example = PostApiProductSubscriptionJsonV700( + consumer_id = ExampleValue.consumerIdExample.value, + start_date = Some(APIUtil.DateWithDayExampleObject), + end_date = None + ) + lazy val putApiProductSubscriptionStatusJsonV700Example = PutApiProductSubscriptionStatusJsonV700(status = "active", end_date = None) + lazy val apiProductSubscriptionAttributeJsonV700Example = ApiProductSubscriptionAttributeJsonV700( + name = "STRIPE_SUBSCRIPTION_ID", `type` = "STRING", value = "sub_1234567890", is_active = Some(true) + ) + lazy val apiProductSubscriptionAttributeResponseJsonV700Example = ApiProductSubscriptionAttributeResponseJsonV700( + api_product_subscription_id = "api-product-subscription-id-123", + api_product_subscription_attribute_id = "api-product-subscription-attribute-id-123", + name = "STRIPE_SUBSCRIPTION_ID", `type` = "STRING", value = "sub_1234567890", is_active = Some(true) + ) + lazy val apiProductSubscriptionJsonV700Example = ApiProductSubscriptionJsonV700( + api_product_subscription_id = "api-product-subscription-id-123", + bank_id = ExampleValue.bankIdExample.value, + api_product_code = ExampleValue.productCodeExample.value, + consumer_id = ExampleValue.consumerIdExample.value, + status = "active", + start_date = APIUtil.DateWithDayExampleObject, + end_date = None, + created_by_user_id = ExampleValue.userIdExample.value, + rate_limiting_id = Some("rate-limiting-id-123"), + created_at = APIUtil.DateWithDayExampleObject, + updated_at = APIUtil.DateWithDayExampleObject, + attributes = Some(List(apiProductSubscriptionAttributeResponseJsonV700Example)) + ) + lazy val apiProductSubscriptionsJsonV700Example = ApiProductSubscriptionsJsonV700(List(apiProductSubscriptionJsonV700Example)) } diff --git a/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscription.scala b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscription.scala new file mode 100644 index 0000000000..e30590d9a2 --- /dev/null +++ b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscription.scala @@ -0,0 +1,84 @@ +package code.apiproductsubscription + +import code.util.{MappedUUID, UUIDString} +import net.liftweb.mapper._ + +import java.util.Date + +/** + * One Consumer holding one API Product for a period, with a status. + * See API_PRODUCT_SUBSCRIPTION_PLAN.md and the Glossary item "API Product Subscription". + */ +class ApiProductSubscription extends ApiProductSubscriptionTrait with LongKeyedMapper[ApiProductSubscription] with IdPK with CreatedUpdated { + def getSingleton = ApiProductSubscription + + object ApiProductSubscriptionId extends MappedUUID(this) + object BankId extends UUIDString(this) + object ApiProductCode extends MappedString(this, 50) + // Consumer.consumerId is a MappedString(250); mirror that rather than UUIDString so any existing id fits. + object ConsumerId extends MappedString(this, 250) + object Status extends MappedString(this, 20) + object StartDate extends MappedDateTime(this) + // null = open-ended + object EndDate extends MappedDateTime(this) + object CreatedByUserId extends UUIDString(this) + // The RateLimiting row Phase 3 creates for this subscription; empty when none. + object RateLimitingId extends MappedString(this, 50) + + override def apiProductSubscriptionId: String = ApiProductSubscriptionId.get + override def bankId: String = BankId.get + override def apiProductCode: String = ApiProductCode.get + override def consumerId: String = ConsumerId.get + override def status: String = Status.get + override def startDate: Date = StartDate.get + override def endDate: Option[Date] = Option(EndDate.get) + override def createdByUserId: String = CreatedByUserId.get + override def rateLimitingId: Option[String] = Option(RateLimitingId.get).filter(_.nonEmpty) + override def createdAtDate: Date = createdAt.get + override def updatedAtDate: Date = updatedAt.get +} + +object ApiProductSubscription extends ApiProductSubscription with LongKeyedMetaMapper[ApiProductSubscription] { + // No unique constraint on (ConsumerId, BankId, ApiProductCode): cancelled rows are history. + // The provider enforces at most one non-cancelled subscription per (consumerId, bankId, apiProductCode). + override def dbIndexes = UniqueIndex(ApiProductSubscriptionId) :: Index(ConsumerId) :: Index(BankId, ApiProductCode) :: super.dbIndexes +} + +trait ApiProductSubscriptionTrait { + def apiProductSubscriptionId: String + def bankId: String + def apiProductCode: String + def consumerId: String + def status: String + def startDate: Date + def endDate: Option[Date] + def createdByUserId: String + def rateLimitingId: Option[String] + def createdAtDate: Date + def updatedAtDate: Date +} + +/** The status machine. Only the transitions listed here are legal; `cancelled` is terminal. */ +object ApiProductSubscriptionStatus { + val Requested = "requested" + val Active = "active" + val PastDue = "past_due" + val Suspended = "suspended" + val Cancelled = "cancelled" + + val all: List[String] = List(Requested, Active, PastDue, Suspended, Cancelled) + + def isValid(status: String): Boolean = all.contains(status) + + private val transitions: Map[String, Set[String]] = Map( + Requested -> Set(Active, Cancelled), + Active -> Set(PastDue, Suspended, Cancelled), + PastDue -> Set(Active, Suspended, Cancelled), + Suspended -> Set(Active, Cancelled), + Cancelled -> Set.empty + ) + + def canTransition(from: String, to: String): Boolean = transitions.get(from).exists(_.contains(to)) + + def allowedFrom(from: String): Set[String] = transitions.getOrElse(from, Set.empty) +} diff --git a/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionEnforcer.scala b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionEnforcer.scala new file mode 100644 index 0000000000..1c1a25de8c --- /dev/null +++ b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionEnforcer.scala @@ -0,0 +1,147 @@ +package code.apiproductsubscription + +import code.api.util.APIUtil.ResourceDoc +import code.api.util.ApiRole +import code.api.util.RoleCombination +import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider +import code.apiproduct.{ApiProductTrait, MappedApiProductsProvider} +import code.ratelimiting.RateLimitingDI +import code.scope.Scope +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import net.liftweb.common.Full + +import java.util.{Calendar, Date, TimeZone} +import scala.concurrent.Future + +/** + * Phase 3 of API_PRODUCT_SUBSCRIPTION_PLAN.md: makes a subscription's status enforceable. + * + * Called after every status change (NewStyle.updateApiProductSubscriptionStatus, which the POST + * auto-activation and the DELETE route also go through). Only three statuses touch anything: + * + * active - one RateLimiting row for the consumer with the product's six limits (-1 copied as + * -1 = unlimited), and a Scope for each Role required by the endpoints in the + * product's API Collection + * suspended - the same row rewritten to six zeros. Rows are summed, so the row grants nothing: + * a consumer with no other active row is blocked (sum 0, 429); Scopes are kept so + * reinstatement is a limits-only change + * cancelled - the row deleted and the Scopes this subscription added removed + * + * requested and past_due are bookkeeping. Everything this object creates is remembered on the + * subscription (RateLimitingId, ApiProductSubscriptionScope rows) and only that is ever touched: + * limits and Scopes granted by hand are never removed. If an admin has deleted the row by hand, a + * fresh one is created and the new id stored; cancelling a subscription whose row is gone is a no-op. + */ +object ApiProductSubscriptionEnforcer extends MdcLoggable { + + /** A RateLimiting row needs a toDate; an open-ended subscription gets this one. */ + val OpenEndedToDate: Date = { + val c = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + c.clear() + c.set(2100, Calendar.JANUARY, 1, 0, 0, 0) + c.getTime + } + + private def rateLimiting = RateLimitingDI.rateLimiting.vend + private def scopes = Scope.scope.vend + private def subscriptions = MappedApiProductSubscriptionsProvider + private def scopeRecords = MappedApiProductSubscriptionScopesProvider + + /** Apply the consequences of the subscription's current status and return the refreshed subscription. */ + def onStatusChanged(subscription: ApiProductSubscriptionTrait): Future[ApiProductSubscriptionTrait] = { + val applied: Future[Unit] = subscription.status match { + case ApiProductSubscriptionStatus.Active => applyActive(subscription) + case ApiProductSubscriptionStatus.Suspended => applySuspended(subscription) + case ApiProductSubscriptionStatus.Cancelled => applyCancelled(subscription) + case _ => Future.successful(()) + } + applied.map(_ => subscriptions.getApiProductSubscriptionById(subscription.apiProductSubscriptionId).getOrElse(subscription)) + } + + private def limitsOf(product: ApiProductTrait): List[Long] = List( + product.perSecondCallLimit, product.perMinuteCallLimit, product.perHourCallLimit, + product.perDayCallLimit, product.perWeekCallLimit, product.perMonthCallLimit + ) + + private def applyActive(subscription: ApiProductSubscriptionTrait): Future[Unit] = + MappedApiProductsProvider.getApiProductByBankIdAndCode(subscription.bankId, subscription.apiProductCode) match { + case Full(product) => + val limits = limitsOf(product) + // A product with no limits at all (all -1) needs no row; but a row may exist from `suspended`, so remove it. + val rowDone = if (limits.exists(_ != -1L)) writeRow(subscription, limits) else deleteRow(subscription) + rowDone.map(_ => grantScopes(subscription, product)) + case _ => + logger.warn(s"ApiProductSubscriptionEnforcer: product ${subscription.bankId}/${subscription.apiProductCode} not found for subscription ${subscription.apiProductSubscriptionId}; nothing enforced") + Future.successful(()) + } + + private def applySuspended(subscription: ApiProductSubscriptionTrait): Future[Unit] = + writeRow(subscription, List.fill(6)(0L)) + + private def applyCancelled(subscription: ApiProductSubscriptionTrait): Future[Unit] = + deleteRow(subscription).map(_ => revokeScopes(subscription)) + + /** Create or rewrite the subscription's own RateLimiting row with these six values. */ + private def writeRow(subscription: ApiProductSubscriptionTrait, limits: List[Long]): Future[Unit] = { + val from = subscription.startDate + val to = subscription.endDate.getOrElse(OpenEndedToDate) + val List(s, m, h, d, w, mo) = limits.map(l => Option(l.toString)) + def create: Future[Unit] = + rateLimiting.createConsumerCallLimits(subscription.consumerId, from, to, None, None, Some(subscription.bankId), s, m, h, d, w, mo).map { + case Full(row) => subscriptions.setRateLimitingId(subscription.apiProductSubscriptionId, Some(row.rateLimitingId)); () + case other => logger.warn(s"ApiProductSubscriptionEnforcer: could not create rate limit row for subscription ${subscription.apiProductSubscriptionId}: $other") + } + subscription.rateLimitingId match { + case Some(id) => + rateLimiting.getByRateLimitingId(id).flatMap { + case Full(_) => rateLimiting.updateConsumerCallLimits(id, from, to, None, None, Some(subscription.bankId), s, m, h, d, w, mo).map(_ => ()) + case _ => create // deleted by hand: start again and remember the new id + } + case None => create + } + } + + /** Delete the subscription's own RateLimiting row, if any, and forget its id. Other rows are never touched. */ + private def deleteRow(subscription: ApiProductSubscriptionTrait): Future[Unit] = + subscription.rateLimitingId match { + case Some(id) => rateLimiting.deleteByRateLimitingId(id).map { _ => subscriptions.setRateLimitingId(subscription.apiProductSubscriptionId, None); () } + case None => Future.successful(()) + } + + /** Roles required by the endpoints in the product's API Collection, RoleCombinations flattened. Empty when no collection. */ + def requiredRoles(product: ApiProductTrait): List[ApiRole] = + Option(product.collectionId).filter(_.nonEmpty) match { + case None => Nil + case Some(collectionId) => + val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(collectionId).map(_.operationId) + ResourceDoc.getResourceDocs(operationIds) + .flatMap(_.roles.getOrElse(Nil)) + .flatMap { case RoleCombination(rs) => rs; case r => List(r) } + .distinct + } + + /** + * Add a Scope per required Role at the product's bank (or "" for roles that are not bank-scoped) and + * record each one added. A Scope that already exists, whether granted by hand or by an earlier + * activation of this subscription, is left alone and not recorded. + */ + private def grantScopes(subscription: ApiProductSubscriptionTrait, product: ApiProductTrait): Unit = + requiredRoles(product).foreach { role => + val scopeBankId = if (role.requiresBankId) product.bankId else "" + scopes.getScope(scopeBankId, subscription.consumerId, role.toString) match { + case Full(_) => () + case _ => + scopes.addScope(scopeBankId, subscription.consumerId, role.toString) + .foreach(scope => scopeRecords.addScopeRecord(subscription.apiProductSubscriptionId, scope.scopeId)) + } + } + + /** Remove exactly the Scopes this subscription recorded, then the records. */ + private def revokeScopes(subscription: ApiProductSubscriptionTrait): Unit = { + scopeRecords.getScopeIds(subscription.apiProductSubscriptionId).foreach { scopeId => + scopes.deleteScope(scopes.getScopeById(scopeId)) + } + scopeRecords.deleteScopeRecords(subscription.apiProductSubscriptionId) + } +} diff --git a/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionScope.scala b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionScope.scala new file mode 100644 index 0000000000..551064a718 --- /dev/null +++ b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionScope.scala @@ -0,0 +1,45 @@ +package code.apiproductsubscription + +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +/** + * Join table recording which Scope rows a subscription created (Phase 3), so that cancelling + * removes exactly those and never a Scope granted by hand. + */ +class ApiProductSubscriptionScope extends LongKeyedMapper[ApiProductSubscriptionScope] with IdPK with CreatedUpdated { + def getSingleton = ApiProductSubscriptionScope + + object ApiProductSubscriptionId extends MappedString(this, 50) + object ScopeId extends MappedString(this, 50) + + def apiProductSubscriptionId: String = ApiProductSubscriptionId.get + def scopeId: String = ScopeId.get +} + +object ApiProductSubscriptionScope extends ApiProductSubscriptionScope with LongKeyedMetaMapper[ApiProductSubscriptionScope] { + override def dbIndexes = Index(ApiProductSubscriptionId) :: super.dbIndexes +} + +object MappedApiProductSubscriptionScopesProvider { + + def addScopeRecord(apiProductSubscriptionId: String, scopeId: String): Box[ApiProductSubscriptionScope] = tryo( + ApiProductSubscriptionScope.create + .ApiProductSubscriptionId(apiProductSubscriptionId) + .ScopeId(scopeId) + .saveMe() + ) + + def getScopeIds(apiProductSubscriptionId: String): List[String] = + ApiProductSubscriptionScope + .findAll(By(ApiProductSubscriptionScope.ApiProductSubscriptionId, apiProductSubscriptionId)) + .map(_.scopeId) + + def deleteScopeRecords(apiProductSubscriptionId: String): Box[Boolean] = tryo { + ApiProductSubscriptionScope + .findAll(By(ApiProductSubscriptionScope.ApiProductSubscriptionId, apiProductSubscriptionId)) + .foreach(_.delete_!) + true + } +} diff --git a/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionsProvider.scala b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionsProvider.scala new file mode 100644 index 0000000000..6fdfd3a744 --- /dev/null +++ b/obp-api/src/main/scala/code/apiproductsubscription/ApiProductSubscriptionsProvider.scala @@ -0,0 +1,115 @@ +package code.apiproductsubscription + +import code.util.Helper.MdcLoggable +import net.liftweb.common.{Box, Failure, Full} +import net.liftweb.mapper.{By, ByList, NotBy} +import net.liftweb.util.Helpers.tryo + +import java.util.Date + +trait ApiProductSubscriptionsProvider { + def createApiProductSubscription( + bankId: String, + apiProductCode: String, + consumerId: String, + status: String, + startDate: Date, + endDate: Option[Date], + createdByUserId: String + ): Box[ApiProductSubscriptionTrait] + + def getApiProductSubscriptionById(apiProductSubscriptionId: String): Box[ApiProductSubscriptionTrait] + + def getApiProductSubscriptionsByConsumerId(consumerId: String): List[ApiProductSubscriptionTrait] + + def getApiProductSubscriptionsByConsumerIds(consumerIds: List[String]): List[ApiProductSubscriptionTrait] + + def getApiProductSubscriptionsByBankIdAndProductCode(bankId: String, apiProductCode: String): List[ApiProductSubscriptionTrait] + + /** The one subscription that is not `cancelled` for this consumer and product, if any. */ + def getNonCancelledApiProductSubscription(consumerId: String, bankId: String, apiProductCode: String): Box[ApiProductSubscriptionTrait] + + /** + * Moves the status. Refuses (Failure) a transition that is not in ApiProductSubscriptionStatus. + * `endDate`, when given, replaces the stored end date. + */ + def updateApiProductSubscriptionStatus(apiProductSubscriptionId: String, newStatus: String, endDate: Option[Date]): Box[ApiProductSubscriptionTrait] + + def setRateLimitingId(apiProductSubscriptionId: String, rateLimitingId: Option[String]): Box[ApiProductSubscriptionTrait] + + def deleteApiProductSubscription(apiProductSubscriptionId: String): Box[Boolean] +} + +object MappedApiProductSubscriptionsProvider extends MdcLoggable with ApiProductSubscriptionsProvider { + + private def find(apiProductSubscriptionId: String): Box[ApiProductSubscription] = + ApiProductSubscription.find(By(ApiProductSubscription.ApiProductSubscriptionId, apiProductSubscriptionId)) + + override def createApiProductSubscription( + bankId: String, + apiProductCode: String, + consumerId: String, + status: String, + startDate: Date, + endDate: Option[Date], + createdByUserId: String + ): Box[ApiProductSubscriptionTrait] = { + if (!ApiProductSubscriptionStatus.isValid(status)) Failure(s"Invalid status: $status") + else tryo { + val row = ApiProductSubscription.create + .BankId(bankId) + .ApiProductCode(apiProductCode) + .ConsumerId(consumerId) + .Status(status) + .StartDate(startDate) + .CreatedByUserId(createdByUserId) + .RateLimitingId("") + endDate.foreach(row.EndDate(_)) + row.saveMe() + } + } + + override def getApiProductSubscriptionById(apiProductSubscriptionId: String): Box[ApiProductSubscriptionTrait] = + find(apiProductSubscriptionId) + + override def getApiProductSubscriptionsByConsumerId(consumerId: String): List[ApiProductSubscriptionTrait] = + ApiProductSubscription.findAll(By(ApiProductSubscription.ConsumerId, consumerId)) + + override def getApiProductSubscriptionsByConsumerIds(consumerIds: List[String]): List[ApiProductSubscriptionTrait] = + if (consumerIds.isEmpty) Nil + else ApiProductSubscription.findAll(ByList(ApiProductSubscription.ConsumerId, consumerIds)) + + override def getApiProductSubscriptionsByBankIdAndProductCode(bankId: String, apiProductCode: String): List[ApiProductSubscriptionTrait] = + ApiProductSubscription.findAll( + By(ApiProductSubscription.BankId, bankId), + By(ApiProductSubscription.ApiProductCode, apiProductCode) + ) + + override def getNonCancelledApiProductSubscription(consumerId: String, bankId: String, apiProductCode: String): Box[ApiProductSubscriptionTrait] = + ApiProductSubscription.find( + By(ApiProductSubscription.ConsumerId, consumerId), + By(ApiProductSubscription.BankId, bankId), + By(ApiProductSubscription.ApiProductCode, apiProductCode), + NotBy(ApiProductSubscription.Status, ApiProductSubscriptionStatus.Cancelled) + ) + + override def updateApiProductSubscriptionStatus(apiProductSubscriptionId: String, newStatus: String, endDate: Option[Date]): Box[ApiProductSubscriptionTrait] = + find(apiProductSubscriptionId).flatMap { row => + if (!ApiProductSubscriptionStatus.canTransition(row.status, newStatus)) + Failure(s"Invalid status transition: ${row.status} -> $newStatus") + else tryo { + row.Status(newStatus) + endDate.foreach(row.EndDate(_)) + row.saveMe() + } + } + + override def setRateLimitingId(apiProductSubscriptionId: String, rateLimitingId: Option[String]): Box[ApiProductSubscriptionTrait] = + find(apiProductSubscriptionId).flatMap(row => tryo(row.RateLimitingId(rateLimitingId.getOrElse("")).saveMe())) + + override def deleteApiProductSubscription(apiProductSubscriptionId: String): Box[Boolean] = + find(apiProductSubscriptionId) match { + case Full(row) => tryo(row.delete_!) + case _ => Full(false) + } +} diff --git a/obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttribute.scala b/obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttribute.scala new file mode 100644 index 0000000000..f15477913c --- /dev/null +++ b/obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttribute.scala @@ -0,0 +1,36 @@ +package code.apiproductsubscriptionattribute + +import code.util.MappedUUID +import net.liftweb.mapper._ + +/** Attributes on an API Product Subscription. Billing adapters store e.g. STRIPE_SUBSCRIPTION_ID here. */ +class ApiProductSubscriptionAttribute extends ApiProductSubscriptionAttributeTrait with LongKeyedMapper[ApiProductSubscriptionAttribute] with IdPK with CreatedUpdated { + def getSingleton = ApiProductSubscriptionAttribute + + object ApiProductSubscriptionId extends MappedString(this, 50) + object ApiProductSubscriptionAttributeId extends MappedUUID(this) + object Name extends MappedString(this, 256) + object Type extends MappedString(this, 50) + object Value extends MappedString(this, 2000) + object IsActive extends MappedBoolean(this) + + override def apiProductSubscriptionId: String = ApiProductSubscriptionId.get + override def apiProductSubscriptionAttributeId: String = ApiProductSubscriptionAttributeId.get + override def name: String = Name.get + override def attributeType: String = Type.get + override def value: String = Value.get + override def isActive: Option[Boolean] = Some(IsActive.get) +} + +object ApiProductSubscriptionAttribute extends ApiProductSubscriptionAttribute with LongKeyedMetaMapper[ApiProductSubscriptionAttribute] { + override def dbIndexes = Index(ApiProductSubscriptionId) :: UniqueIndex(ApiProductSubscriptionAttributeId) :: super.dbIndexes +} + +trait ApiProductSubscriptionAttributeTrait { + def apiProductSubscriptionId: String + def apiProductSubscriptionAttributeId: String + def name: String + def attributeType: String + def value: String + def isActive: Option[Boolean] +} diff --git a/obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttributesProvider.scala b/obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttributesProvider.scala new file mode 100644 index 0000000000..3336d36306 --- /dev/null +++ b/obp-api/src/main/scala/code/apiproductsubscriptionattribute/ApiProductSubscriptionAttributesProvider.scala @@ -0,0 +1,80 @@ +package code.apiproductsubscriptionattribute + +import code.util.Helper.MdcLoggable +import net.liftweb.common.Box +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.tryo + +trait ApiProductSubscriptionAttributesProvider { + def getApiProductSubscriptionAttributes(apiProductSubscriptionId: String): Box[List[ApiProductSubscriptionAttributeTrait]] + + def getApiProductSubscriptionAttributeById(apiProductSubscriptionAttributeId: String): Box[ApiProductSubscriptionAttributeTrait] + + def createOrUpdateApiProductSubscriptionAttribute( + apiProductSubscriptionId: String, + apiProductSubscriptionAttributeId: Option[String], + name: String, + attributeType: String, + value: String, + isActive: Option[Boolean] + ): Box[ApiProductSubscriptionAttributeTrait] + + def deleteApiProductSubscriptionAttribute(apiProductSubscriptionAttributeId: String): Box[Boolean] + + def deleteApiProductSubscriptionAttributes(apiProductSubscriptionId: String): Box[Boolean] +} + +object MappedApiProductSubscriptionAttributesProvider extends MdcLoggable with ApiProductSubscriptionAttributesProvider { + + override def getApiProductSubscriptionAttributes(apiProductSubscriptionId: String): Box[List[ApiProductSubscriptionAttributeTrait]] = + tryo(ApiProductSubscriptionAttribute.findAll(By(ApiProductSubscriptionAttribute.ApiProductSubscriptionId, apiProductSubscriptionId))) + + override def getApiProductSubscriptionAttributeById(apiProductSubscriptionAttributeId: String): Box[ApiProductSubscriptionAttributeTrait] = + ApiProductSubscriptionAttribute.find(By(ApiProductSubscriptionAttribute.ApiProductSubscriptionAttributeId, apiProductSubscriptionAttributeId)) + + override def createOrUpdateApiProductSubscriptionAttribute( + apiProductSubscriptionId: String, + apiProductSubscriptionAttributeId: Option[String], + name: String, + attributeType: String, + value: String, + isActive: Option[Boolean] + ): Box[ApiProductSubscriptionAttributeTrait] = { + val existing = apiProductSubscriptionAttributeId.flatMap(id => + ApiProductSubscriptionAttribute.find(By(ApiProductSubscriptionAttribute.ApiProductSubscriptionAttributeId, id))) + existing match { + case Some(row) => + tryo( + row + .ApiProductSubscriptionId(apiProductSubscriptionId) + .Name(name) + .Type(attributeType) + .Value(value) + .IsActive(isActive.getOrElse(true)) + .saveMe() + ) + case None => + tryo( + ApiProductSubscriptionAttribute.create + .ApiProductSubscriptionId(apiProductSubscriptionId) + .Name(name) + .Type(attributeType) + .Value(value) + .IsActive(isActive.getOrElse(true)) + .saveMe() + ) + } + } + + override def deleteApiProductSubscriptionAttribute(apiProductSubscriptionAttributeId: String): Box[Boolean] = + ApiProductSubscriptionAttribute + .find(By(ApiProductSubscriptionAttribute.ApiProductSubscriptionAttributeId, apiProductSubscriptionAttributeId)) + .map(_.delete_!) + + override def deleteApiProductSubscriptionAttributes(apiProductSubscriptionId: String): Box[Boolean] = tryo { + ApiProductSubscriptionAttribute + .findAll(By(ApiProductSubscriptionAttribute.ApiProductSubscriptionId, apiProductSubscriptionId)) + .foreach(_.delete_!) + true + } +} diff --git a/obp-api/src/test/scala/code/api/cache/CacheKeyFormatTest.scala b/obp-api/src/test/scala/code/api/cache/CacheKeyFormatTest.scala index 2f18bd3eda..20fcc49c42 100644 --- a/obp-api/src/test/scala/code/api/cache/CacheKeyFormatTest.scala +++ b/obp-api/src/test/scala/code/api/cache/CacheKeyFormatTest.scala @@ -102,6 +102,33 @@ class CacheKeyFormatTest extends FlatSpec with Matchers { InMemory.countKeys(glob) should be >= 1 } + it should "keep the pattern rate-limit invalidation depends on matchable" in { + // Caching.invalidateRateLimitCache(consumerId) issues + // deleteKeysByPattern(s"*${RATE_LIMIT_ACTIVE_PREFIX}${consumerId}_*"). The cache key + // MappedRateLimiting writes is s"${RATE_LIMIT_ACTIVE_PREFIX}${consumerId}_${dateWithHour}", + // so the glob must match the derived key for that string. Before the leading "*" was added + // the glob was anchored at the front and matched nothing: every create/update/delete of a + // rate limit logged "Deleted 0 Redis keys" and the change waited for the hour cache to expire. + val prefix = code.api.Constant.RATE_LIMIT_ACTIVE_PREFIX + val consumerId = java.util.UUID.randomUUID().toString + val marker = s"${prefix}${consumerId}_2026-09-02-12" + val before = storedKeys + Caching.memoizeSyncWithImMemory(Some(marker))(ttl)("limits") + val added = (storedKeys -- before).head + + val glob = s"*${prefix}${consumerId}_*" + val regex = glob.replace("*", ".*").replace("(", "\\(").replace(")", "\\)") + withClue(s"derived key '$added' is not matched by the invalidation pattern '$glob'. " + + s"Caching.invalidateRateLimitCache would delete nothing and report nothing. ") { + added.matches(regex) shouldBe true + } + InMemory.countKeys(glob) should be >= 1 + + // And the anchored form, the bug, must NOT match -- otherwise this test proves nothing. + val anchored = s"${prefix}${consumerId}_*".replace("*", ".*") + added.matches(anchored) shouldBe false + } + it should "give different callers different keys" in { // A derivation that collapsed distinct callers onto one key would serve one caller's value // to another -- and every substring assertion in the suite would still pass. diff --git a/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala index b111dddb31..685ea44dbc 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala @@ -28,7 +28,7 @@ package code.api.v6_0_0 import org.json4s._ import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.{CanCreateRateLimits, CanDeleteRateLimits, CanGetRateLimits} -import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired} +import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired, TooManyRequests} import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 import code.consumer.Consumers import code.entitlement.Entitlement @@ -49,6 +49,7 @@ class RateLimitsTest extends V600ServerSetup { object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.deleteCallLimits)) object UpdateRateLimits extends Tag(nameOf(Implementations6_0_0.updateRateLimits)) object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.getActiveRateLimitsAtDate)) + object ApiEndpoint4 extends Tag(nameOf(Implementations6_0_0.getActiveRateLimitsNow)) lazy val postCallLimitJsonV600 = CallLimitPostJsonV600( from_date = new Date(), @@ -179,8 +180,9 @@ class RateLimitsTest extends V600ServerSetup { getResponse.code should equal(200) And("we should get the active call limits response") val activeCallLimits = getResponse.body.extract[ActiveRateLimitsJsonV600] - activeCallLimits.considered_rate_limit_ids.size >= 0 - activeCallLimits.active_per_second_rate_limit == 0L + activeCallLimits.considered_rate_limit_ids should not be empty + // other scenarios may have left records for this consumer; the record created above contributes 10 + activeCallLimits.active_per_second_rate_limit should be >= 10L } scenario("We will try to get active call limits without proper role", ApiEndpoint3, VersionOfApi) { @@ -266,4 +268,141 @@ class RateLimitsTest extends V600ServerSetup { activeCallLimits.active_per_month_rate_limit should equal(-1L) // -1 (both are -1, so unlimited) } } + + // --------------------------------------------------------------------------------------------- + // Value semantics: 0 blocks, -1 is unlimited, no record means the system default. + // These scenarios use consumer3 (user3), which no other scenario in this class touches, and + // delete every record they create so that later test classes are not affected. + // --------------------------------------------------------------------------------------------- + + lazy val consumerId3: String = Consumers.consumers.vend.getConsumerByConsumerKey(consumer3.key).map(_.consumerId.get).getOrElse("") + + def callLimitJson(perSecond: String, perMinute: String, perHour: String): CallLimitPostJsonV600 = CallLimitPostJsonV600( + from_date = new Date(System.currentTimeMillis() - 3600000L), // one hour ago, so the current hour is covered + to_date = new Date(System.currentTimeMillis() + 86400000L), // one day ahead + api_version = None, + api_name = None, + bank_id = None, + per_second_call_limit = perSecond, + per_minute_call_limit = perMinute, + per_hour_call_limit = perHour, + per_day_call_limit = "-1", + per_week_call_limit = "-1", + per_month_call_limit = "-1" + ) + + def createLimit(consumerId: String, json: CallLimitPostJsonV600): String = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateRateLimits.toString) + val response = makePostRequest((v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "rate-limits").POST <@ (user1), write(json)) + response.code should equal(201) + response.body.extract[CallLimitJsonV600].rate_limiting_id + } + + def deleteLimit(consumerId: String, rateLimitingId: String): Unit = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteRateLimits.toString) + makeDeleteRequest((v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "rate-limits" / rateLimitingId).DELETE <@ (user1)).code should equal(204) + } + + def activeLimitsNow(consumerId: String): ActiveRateLimitsJsonV600 = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetRateLimits.toString) + val response = makeGetRequest((v6_0_0_Request / "management" / "consumers" / consumerId / "active-rate-limits").GET <@ (user1)) + response.code should equal(200) + response.body.extract[ActiveRateLimitsJsonV600] + } + + def callAsUser3() = makeGetRequest((v6_0_0_Request / "users" / "current").GET <@ (user3)) + + feature("Rate limit values v6.0.0: 0 blocks, -1 is unlimited, no record means the system default") { + + scenario("A consumer with no rate limit records gets the system defaults", ApiEndpoint4, VersionOfApi) { + When("We get the active rate limits of a consumer that has no records") + val limits = activeLimitsNow(consumerId3) + Then("No record is considered and every period shows the system default (-1 in the test props)") + limits.considered_rate_limit_ids shouldBe empty + limits.active_per_second_rate_limit should equal(-1L) + limits.active_per_minute_rate_limit should equal(-1L) + limits.active_per_hour_rate_limit should equal(-1L) + limits.active_per_day_rate_limit should equal(-1L) + limits.active_per_week_rate_limit should equal(-1L) + limits.active_per_month_rate_limit should equal(-1L) + And("the consumer can call the API") + callAsUser3().code should equal(200) + } + + scenario("A record with 0 blocks the consumer and deleting it unblocks", ApiEndpoint1, ApiEndpoint4, VersionOfApi) { + Given("The consumer can call the API") + callAsUser3().code should equal(200) + When("We create a record with 0 per second, per minute and per hour") + val zeroId = createLimit(consumerId3, callLimitJson("0", "0", "0")) + try { + Then("The active rate limits report 0 for those periods and -1 for the rest") + val limits = activeLimitsNow(consumerId3) + limits.considered_rate_limit_ids should equal(List(zeroId)) + limits.active_per_second_rate_limit should equal(0L) + limits.active_per_minute_rate_limit should equal(0L) + limits.active_per_hour_rate_limit should equal(0L) + limits.active_per_day_rate_limit should equal(-1L) + And("every call by the consumer is refused with 429") + val blocked = callAsUser3() + blocked.code should equal(429) + val message = blocked.body.extract[ErrorMessage].message + message should startWith(TooManyRequests) + message should include("blocked") + message should include(consumerId3) + } finally { + deleteLimit(consumerId3, zeroId) + } + And("after deleting the record the consumer can call the API again") + callAsUser3().code should equal(200) + activeLimitsNow(consumerId3).considered_rate_limit_ids shouldBe empty + } + + scenario("A 0 record adds nothing to a positive record; it blocks only once the sum is 0", ApiEndpoint4, VersionOfApi) { + Given("A positive record and a record that is 0 per second only") + val positiveId = createLimit(consumerId3, callLimitJson("10", "100", "1000")) + val zeroId = createLimit(consumerId3, callLimitJson("0", "-1", "-1")) + var positiveDeleted = false + try { + When("We get the active rate limits") + val limits = activeLimitsNow(consumerId3) + Then("every period is the positive record's value: the 0 does not override it") + limits.considered_rate_limit_ids.toSet should equal(Set(positiveId, zeroId)) + limits.active_per_second_rate_limit should equal(10L) + limits.active_per_minute_rate_limit should equal(100L) + limits.active_per_hour_rate_limit should equal(1000L) + limits.active_per_day_rate_limit should equal(-1L) + And("the consumer can call the API") + callAsUser3().code should equal(200) + + When("the positive record is deleted, the 0 record is all that is left") + deleteLimit(consumerId3, positiveId) + positiveDeleted = true + Then("the per-second sum is 0 and the consumer is blocked") + activeLimitsNow(consumerId3).active_per_second_rate_limit should equal(0L) + callAsUser3().code should equal(429) + } finally { + if (!positiveDeleted) deleteLimit(consumerId3, positiveId) + deleteLimit(consumerId3, zeroId) + } + callAsUser3().code should equal(200) + } + + scenario("A record with -1 in every period is unlimited, not blocked", ApiEndpoint4, VersionOfApi) { + Given("A record with -1 everywhere") + val id = createLimit(consumerId3, callLimitJson("-1", "-1", "-1")) + try { + When("We get the active rate limits") + val limits = activeLimitsNow(consumerId3) + Then("the record is considered and every period is -1") + limits.considered_rate_limit_ids should equal(List(id)) + limits.active_per_second_rate_limit should equal(-1L) + limits.active_per_minute_rate_limit should equal(-1L) + limits.active_per_hour_rate_limit should equal(-1L) + And("the consumer can call the API") + callAsUser3().code should equal(200) + } finally { + deleteLimit(consumerId3, id) + } + } + } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/ApiProductSubscriptionTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/ApiProductSubscriptionTest.scala new file mode 100644 index 0000000000..a2094c3d69 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/ApiProductSubscriptionTest.scala @@ -0,0 +1,514 @@ +package code.api.v7_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.ErrorMessages._ +import code.api.v6_0_0.{ActiveRateLimitsJsonV600, ApiProductAttributeJsonV600, PostPutApiProductJsonV600} +import code.apicollection.MappedApiCollectionsProvider +import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider +import code.scope.Scope +import code.api.v7_0_0.JSONFactory700.{ApiProductSubscriptionAttributeJsonV700, ApiProductSubscriptionAttributeResponseJsonV700, ApiProductSubscriptionJsonV700, ApiProductSubscriptionsJsonV700, PostApiProductSubscriptionJsonV700, PutApiProductSubscriptionStatusJsonV700} +import code.api.v7_0_0.Http4s700.Implementations7_0_0 +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.setup.ServerSetupWithTestData +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.json4s._ +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +import java.util.UUID + +/** + * API Product Subscription (Phase 2 of API_PRODUCT_SUBSCRIPTION_PLAN.md). + * + * The API Product itself is a v6.0.0 resource, so products and their attributes are created through + * v6.0.0; every subscription endpoint under test is v7.0.0. + * + * user1 owns consumer1 (created_by_user_id = userId1), user2 owns consumer2. Entitlements accumulate + * on a user across scenarios, so each "without the role" check uses a role that no earlier scenario + * granted to that user. + */ +class ApiProductSubscriptionTest extends ServerSetupWithTestData { + + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations7_0_0.createApiProductSubscription)) + object ApiEndpoint2 extends Tag(nameOf(Implementations7_0_0.getMyApiProductSubscriptions)) + object ApiEndpoint3 extends Tag(nameOf(Implementations7_0_0.getMyApiProductSubscription)) + object ApiEndpoint4 extends Tag(nameOf(Implementations7_0_0.updateMyApiProductSubscriptionStatus)) + object ApiEndpoint5 extends Tag(nameOf(Implementations7_0_0.getApiProductSubscriptionsByProduct)) + object ApiEndpoint6 extends Tag(nameOf(Implementations7_0_0.getConsumerApiProductSubscriptions)) + object ApiEndpoint7 extends Tag(nameOf(Implementations7_0_0.getApiProductSubscription)) + object ApiEndpoint8 extends Tag(nameOf(Implementations7_0_0.updateApiProductSubscriptionStatus)) + object ApiEndpoint9 extends Tag(nameOf(Implementations7_0_0.deleteApiProductSubscription)) + object ApiEndpoint10 extends Tag(nameOf(Implementations7_0_0.createApiProductSubscriptionAttribute)) + object ApiEndpoint11 extends Tag(nameOf(Implementations7_0_0.updateApiProductSubscriptionAttribute)) + object ApiEndpoint12 extends Tag(nameOf(Implementations7_0_0.getApiProductSubscriptionAttributes)) + object ApiEndpoint13 extends Tag(nameOf(Implementations7_0_0.deleteApiProductSubscriptionAttribute)) + + def v6 = baseRequest / "obp" / "v6.0.0" + def v7 = baseRequest / "obp" / "v7.0.0" + + lazy val bankId: String = testBankId1.value + + // Ownership means Consumer.createdByUserId == caller.userId. The harness's default consumers are + // stamped with TestServer.userIdN, which need not equal resourceUserN.userId when the resource user + // already existed, so create consumers whose owner is exactly the resource user we call as. + def createOwnedConsumer(ownerUserId: String, label: String): String = Consumers.consumers.vend.createConsumer( + key = Some(UUID.randomUUID().toString.replace("-", "")), + secret = Some(UUID.randomUUID().toString.replace("-", "")), + isActive = Some(true), + name = Some(s"api-product-subscription-test-$label"), + appType = None, + description = Some("created by ApiProductSubscriptionTest"), + developerEmail = Some(s"$label@example.com"), + redirectURL = None, + createdByUserId = Some(ownerUserId), + None, None, None + ).map(_.consumerId.get).openOrThrowException("could not create test consumer") + lazy val consumerId1: String = createOwnedConsumer(resourceUser1.userId, "user1") + lazy val consumerId2: String = createOwnedConsumer(resourceUser2.userId, "user2") + + def newProductCode(): String = "sub-test-" + UUID.randomUUID().toString.take(8) + + def grant(userId: String, bank: String, role: String): Unit = + Entitlement.entitlement.vend.addEntitlement(bank, userId, role) + + def createProduct(code: String, collectionId: Option[String] = None): Unit = { + grant(resourceUser1.userId, bankId, CanCreateApiProduct.toString) + val json = PostPutApiProductJsonV600( + parent_api_product_code = None, name = s"Subscription test $code", category = None, + more_info_url = None, terms_and_conditions_url = None, description = None, collection_id = collectionId, + monthly_subscription_currency = None, monthly_subscription_amount = None, + per_second_call_limit = Some(10L), per_minute_call_limit = Some(100L), per_hour_call_limit = Some(-1L), + per_day_call_limit = Some(-1L), per_week_call_limit = Some(-1L), per_month_call_limit = Some(-1L), tags = None) + val response = makePostRequest((v6 / "banks" / bankId / "api-products" / code).POST <@ (user1), write(json)) + response.code should equal(201) + } + + def setProductAttribute(code: String, name: String, value: String): Unit = { + grant(resourceUser1.userId, bankId, CanCreateApiProductAttribute.toString) + val json = ApiProductAttributeJsonV600(name = name, `type` = "STRING", value = value, is_active = Some(true)) + val response = makePostRequest((v6 / "banks" / bankId / "api-products" / code / "attribute").POST <@ (user1), write(json)) + response.code should equal(201) + } + + def subscribe(code: String, consumerId: String, as: Option[(Consumer, Token)]) = + makePostRequest((v7 / "banks" / bankId / "api-products" / code / "subscriptions").POST <@ (as), + write(PostApiProductSubscriptionJsonV700(consumer_id = consumerId, start_date = None, end_date = None))) + + def subscribed(code: String, consumerId: String, as: Option[(Consumer, Token)]): ApiProductSubscriptionJsonV700 = { + val response = subscribe(code, consumerId, as) + response.code should equal(201) + response.body.extract[ApiProductSubscriptionJsonV700] + } + + def putMyStatus(id: String, status: String, as: Option[(Consumer, Token)]) = + makePutRequest((v7 / "my" / "api-product-subscriptions" / id / "status").PUT <@ (as), + write(PutApiProductSubscriptionStatusJsonV700(status = status, end_date = None))) + + def putStatus(id: String, status: String, as: Option[(Consumer, Token)]) = + makePutRequest((v7 / "management" / "api-product-subscriptions" / id / "status").PUT <@ (as), + write(PutApiProductSubscriptionStatusJsonV700(status = status, end_date = None))) + + def errorOf(response: code.setup.APIResponse): String = response.body.extract[ErrorMessage].message + + feature("Create subscription: developer self-service, /my endpoints, cancel") { + scenario("Own consumer, open product: active at once, then list, get, cancel, re-subscribe", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) { + val code = newProductCode() + createProduct(code) + + When("user1 subscribes their own consumer without any role") + val created = subscribed(code, consumerId1, user1) + Then("the subscription is active at once, BILLING_SYSTEM being absent") + created.status should equal("active") + created.consumer_id should equal(consumerId1) + created.bank_id should equal(bankId) + created.api_product_code should equal(code) + created.created_by_user_id should equal(resourceUser1.userId) + + And("a second subscription to the same product is refused with 409") + val duplicate = subscribe(code, consumerId1, user1) + duplicate.code should equal(409) + errorOf(duplicate) should startWith(ApiProductSubscriptionAlreadyExists) + + And("it is listed under /my") + val mine = makeGetRequest((v7 / "my" / "api-product-subscriptions").GET <@ (user1)) + mine.code should equal(200) + mine.body.extract[ApiProductSubscriptionsJsonV700].api_product_subscriptions.map(_.api_product_subscription_id) should contain(created.api_product_subscription_id) + + And("the owner can read it, another developer gets 404") + makeGetRequest((v7 / "my" / "api-product-subscriptions" / created.api_product_subscription_id).GET <@ (user1)).code should equal(200) + val other = makeGetRequest((v7 / "my" / "api-product-subscriptions" / created.api_product_subscription_id).GET <@ (user2)) + other.code should equal(404) + errorOf(other) should startWith(ApiProductSubscriptionNotFound) + + And("the owner may not set any status but cancelled") + val activate = putMyStatus(created.api_product_subscription_id, "active", user1) + activate.code should equal(400) + errorOf(activate) should startWith(InvalidApiProductSubscriptionStatusTransition) + + And("another developer may not cancel it") + val foreignCancel = putMyStatus(created.api_product_subscription_id, "cancelled", user2) + foreignCancel.code should equal(403) + errorOf(foreignCancel) should startWith(ConsumerNotOwnedByUser) + + And("the owner cancels it") + val cancelled = putMyStatus(created.api_product_subscription_id, "cancelled", user1) + cancelled.code should equal(200) + cancelled.body.extract[ApiProductSubscriptionJsonV700].status should equal("cancelled") + + And("after cancelling, a new subscription can be created") + val again = subscribed(code, consumerId1, user1) + again.api_product_subscription_id should not equal created.api_product_subscription_id + } + + scenario("consumer_id is required and must exist; anonymous is 401", ApiEndpoint1, VersionOfApi) { + val code = newProductCode() + createProduct(code) + subscribe(code, "", user1).code should equal(400) + val unknown = subscribe(code, "no-such-consumer-" + UUID.randomUUID(), user1) + unknown.code should equal(404) + errorOf(unknown) should startWith(ConsumerNotFoundByConsumerId) + subscribe(code, consumerId1, None).code should equal(401) + // The v6.0.0 API Product lookup reports a missing product with 400 (existing convention). + val noProduct = subscribe("no-such-product", consumerId1, user1) + noProduct.code should equal(400) + errorOf(noProduct) should startWith(ApiProductNotFound) + } + + scenario("Someone else's consumer needs the create role; the bank enrols a partner's consumer", ApiEndpoint1, VersionOfApi) { + val code = newProductCode() + createProduct(code) + When("user1 tries to subscribe consumer2, which user2 created, without the role") + val refused = subscribe(code, consumerId2, user1) + Then("403") + refused.code should equal(403) + errorOf(refused) should startWith(UserHasMissingRoles) + When("user1 holds CanCreateApiProductSubscriptionAtOneBank at the product's bank") + grant(resourceUser1.userId, bankId, CanCreateApiProductSubscriptionAtOneBank.toString) + Then("the enrolment succeeds") + val enrolled = subscribed(code, consumerId2, user1) + enrolled.consumer_id should equal(consumerId2) + enrolled.created_by_user_id should equal(resourceUser1.userId) + } + + scenario("SELF_SUBSCRIBE=false closes a product to self-service; the role at the product's bank reopens it", ApiEndpoint1, VersionOfApi) { + val code = newProductCode() + createProduct(code) + setProductAttribute(code, "SELF_SUBSCRIBE", "false") + When("user2 subscribes their own consumer without a role") + val refused = subscribe(code, consumerId2, user2) + Then("403") + refused.code should equal(403) + errorOf(refused) should startWith(UserHasMissingRoles) + When("user2 holds CanCreateApiProductSubscriptionAtOneBank at the product's bank") + grant(resourceUser2.userId, bankId, CanCreateApiProductSubscriptionAtOneBank.toString) + Then("201") + subscribed(code, consumerId2, user2).status should equal("active") + } + } + + feature("Status machine via /management, BILLING_SYSTEM=manual") { + scenario("requested until a bank admin activates; every transition; invalid ones refused", ApiEndpoint8, VersionOfApi) { + val code = newProductCode() + createProduct(code) + setProductAttribute(code, "BILLING_SYSTEM", "manual") + val created = subscribed(code, consumerId1, user1) + created.status should equal("requested") + val id = created.api_product_subscription_id + + When("user2 tries to activate without the role") + val noRole = putStatus(id, "active", user2) + noRole.code should equal(403) + errorOf(noRole) should startWith(UserHasMissingRoles) + + When("user2 holds the AtOneBank role at the wrong bank") + grant(resourceUser2.userId, "some-other-bank", CanUpdateApiProductSubscriptionStatusAtOneBank.toString) + Then("still 403") + putStatus(id, "active", user2).code should equal(403) + + When("user2 holds the AtOneBank role at the product's bank") + grant(resourceUser2.userId, bankId, CanUpdateApiProductSubscriptionStatusAtOneBank.toString) + Then("requested -> active") + val activated = putStatus(id, "active", user2) + activated.code should equal(200) + activated.body.extract[ApiProductSubscriptionJsonV700].status should equal("active") + + And("an unknown status is refused") + val bogus = putStatus(id, "bogus", user2) + bogus.code should equal(400) + errorOf(bogus) should startWith(InvalidApiProductSubscriptionStatus) + + And("active -> requested is refused") + val backwards = putStatus(id, "requested", user2) + backwards.code should equal(400) + errorOf(backwards) should startWith(InvalidApiProductSubscriptionStatusTransition) + + And("active -> past_due -> suspended -> active -> cancelled") + putStatus(id, "past_due", user2).body.extract[ApiProductSubscriptionJsonV700].status should equal("past_due") + putStatus(id, "suspended", user2).body.extract[ApiProductSubscriptionJsonV700].status should equal("suspended") + putStatus(id, "active", user2).body.extract[ApiProductSubscriptionJsonV700].status should equal("active") + putStatus(id, "cancelled", user2).body.extract[ApiProductSubscriptionJsonV700].status should equal("cancelled") + + And("cancelled is terminal") + val revive = putStatus(id, "active", user2) + revive.code should equal(400) + errorOf(revive) should startWith(InvalidApiProductSubscriptionStatusTransition) + } + } + + feature("Management reads") { + scenario("by product, by id, and by consumer filtered to the banks where the role is held", ApiEndpoint5, ApiEndpoint6, ApiEndpoint7, VersionOfApi) { + val code = newProductCode() + createProduct(code) + val created = subscribed(code, consumerId1, user1) + + When("user2 lists subscribers of the product without the role") + val noRole = makeGetRequest((v7 / "banks" / bankId / "api-products" / code / "subscriptions").GET <@ (user2)) + Then("403") + noRole.code should equal(403) + + And("by consumer is refused too: user2 neither created the consumer nor holds the role anywhere") + val byConsumerNoRole = makeGetRequest((v7 / "management" / "consumers" / consumerId1 / "api-product-subscriptions").GET <@ (user2)) + byConsumerNoRole.code should equal(403) + errorOf(byConsumerNoRole) should startWith(UserHasMissingRoles) + + And("with the role at another bank only, by consumer answers 200 but hides this bank's subscription") + grant(resourceUser2.userId, "some-other-bank", CanGetApiProductSubscriptionAtOneBank.toString) + val byConsumerOtherBank = makeGetRequest((v7 / "management" / "consumers" / consumerId1 / "api-product-subscriptions").GET <@ (user2)) + byConsumerOtherBank.code should equal(200) + byConsumerOtherBank.body.extract[ApiProductSubscriptionsJsonV700].api_product_subscriptions.map(_.api_product_subscription_id) should not contain created.api_product_subscription_id + + When("user2 holds CanGetApiProductSubscriptionAtOneBank at the product's bank") + grant(resourceUser2.userId, bankId, CanGetApiProductSubscriptionAtOneBank.toString) + val byProduct = makeGetRequest((v7 / "banks" / bankId / "api-products" / code / "subscriptions").GET <@ (user2)) + byProduct.code should equal(200) + byProduct.body.extract[ApiProductSubscriptionsJsonV700].api_product_subscriptions.map(_.api_product_subscription_id) should contain(created.api_product_subscription_id) + + And("by id works with the same role") + val byId = makeGetRequest((v7 / "management" / "api-product-subscriptions" / created.api_product_subscription_id).GET <@ (user2)) + byId.code should equal(200) + byId.body.extract[ApiProductSubscriptionJsonV700].api_product_subscription_id should equal(created.api_product_subscription_id) + + And("by consumer now shows the subscription at the bank where the role is held") + val byConsumer = makeGetRequest((v7 / "management" / "consumers" / consumerId1 / "api-product-subscriptions").GET <@ (user2)) + byConsumer.code should equal(200) + byConsumer.body.extract[ApiProductSubscriptionsJsonV700].api_product_subscriptions.map(_.api_product_subscription_id) should contain(created.api_product_subscription_id) + + And("the consumer's creator sees them all without any role") + val byConsumerOwner = makeGetRequest((v7 / "management" / "consumers" / consumerId1 / "api-product-subscriptions").GET <@ (user1)) + byConsumerOwner.code should equal(200) + byConsumerOwner.body.extract[ApiProductSubscriptionsJsonV700].api_product_subscriptions.map(_.api_product_subscription_id) should contain(created.api_product_subscription_id) + } + } + + feature("Attributes and delete") { + scenario("create, list, update, delete attributes; the owner sees them; delete the subscription", ApiEndpoint9, ApiEndpoint10, ApiEndpoint11, ApiEndpoint12, ApiEndpoint13, VersionOfApi) { + val code = newProductCode() + createProduct(code) + val created = subscribed(code, consumerId1, user1) + val id = created.api_product_subscription_id + val attributeJson = write(ApiProductSubscriptionAttributeJsonV700(name = "STRIPE_SUBSCRIPTION_ID", `type` = "STRING", value = "sub_1", is_active = Some(true))) + + When("user2 creates an attribute without the role") + makePostRequest((v7 / "management" / "api-product-subscriptions" / id / "attribute").POST <@ (user2), attributeJson).code should equal(403) + When("user2 holds CanCreateApiProductSubscriptionAttributeAtOneBank at the subscription's bank") + grant(resourceUser2.userId, bankId, CanCreateApiProductSubscriptionAttributeAtOneBank.toString) + val createdAttribute = makePostRequest((v7 / "management" / "api-product-subscriptions" / id / "attribute").POST <@ (user2), attributeJson) + createdAttribute.code should equal(201) + val attribute = createdAttribute.body.extract[ApiProductSubscriptionAttributeResponseJsonV700] + attribute.api_product_subscription_id should equal(id) + attribute.value should equal("sub_1") + + And("the attributes can be listed with the get role (granted earlier at this bank)") + grant(resourceUser2.userId, bankId, CanGetApiProductSubscriptionAtOneBank.toString) + val listed = makeGetRequest((v7 / "management" / "api-product-subscriptions" / id / "attributes").GET <@ (user2)) + listed.code should equal(200) + listed.body.extract[List[ApiProductSubscriptionAttributeResponseJsonV700]].map(_.api_product_subscription_attribute_id) should contain(attribute.api_product_subscription_attribute_id) + + And("the owner sees the attribute on their subscription without any role") + val mine = makeGetRequest((v7 / "my" / "api-product-subscriptions" / id).GET <@ (user1)) + mine.code should equal(200) + mine.body.extract[ApiProductSubscriptionJsonV700].attributes.getOrElse(Nil).map(_.value) should contain("sub_1") + + And("the attribute can be updated with the update role") + val updateJson = write(ApiProductSubscriptionAttributeJsonV700(name = "STRIPE_SUBSCRIPTION_ID", `type` = "STRING", value = "sub_2", is_active = Some(true))) + makePutRequest((v7 / "management" / "api-product-subscriptions" / id / "attributes" / attribute.api_product_subscription_attribute_id).PUT <@ (user2), updateJson).code should equal(403) + grant(resourceUser2.userId, bankId, CanUpdateApiProductSubscriptionAttributeAtOneBank.toString) + val updated = makePutRequest((v7 / "management" / "api-product-subscriptions" / id / "attributes" / attribute.api_product_subscription_attribute_id).PUT <@ (user2), updateJson) + updated.code should equal(200) + updated.body.extract[ApiProductSubscriptionAttributeResponseJsonV700].value should equal("sub_2") + + And("an attribute of another subscription is not reachable through this one") + val otherCode = newProductCode() + createProduct(otherCode) + val otherId = subscribed(otherCode, consumerId1, user1).api_product_subscription_id + makePutRequest((v7 / "management" / "api-product-subscriptions" / otherId / "attributes" / attribute.api_product_subscription_attribute_id).PUT <@ (user2), updateJson).code should equal(404) + + And("the attribute can be deleted with the delete role") + makeDeleteRequest((v7 / "management" / "api-product-subscriptions" / id / "attributes" / attribute.api_product_subscription_attribute_id).DELETE <@ (user2)).code should equal(403) + grant(resourceUser2.userId, bankId, CanDeleteApiProductSubscriptionAttributeAtOneBank.toString) + makeDeleteRequest((v7 / "management" / "api-product-subscriptions" / id / "attributes" / attribute.api_product_subscription_attribute_id).DELETE <@ (user2)).code should equal(204) + makeGetRequest((v7 / "management" / "api-product-subscriptions" / id / "attributes").GET <@ (user2)).body.extract[List[ApiProductSubscriptionAttributeResponseJsonV700]] shouldBe empty + + And("the subscription can be deleted with the delete role, and is then gone") + makeDeleteRequest((v7 / "management" / "api-product-subscriptions" / id).DELETE <@ (user2)).code should equal(403) + grant(resourceUser2.userId, bankId, CanDeleteApiProductSubscriptionAtOneBank.toString) + makeDeleteRequest((v7 / "management" / "api-product-subscriptions" / id).DELETE <@ (user2)).code should equal(204) + val gone = makeGetRequest((v7 / "management" / "api-product-subscriptions" / id).GET <@ (user2)) + gone.code should equal(404) + errorOf(gone) should startWith(ApiProductSubscriptionNotFound) + } + } + + // ─── Phase 3: enforcement ───────────────────────────────────────────────────────────────────── + + /** An API Collection holding one endpoint that requires CanCreateApiProduct (a bank-scoped role). */ + def createCollectionRequiring(operationId: String): String = { + val collection = MappedApiCollectionsProvider.createApiCollection(resourceUser1.userId, "sub-test-" + UUID.randomUUID().toString.take(8), true, "ApiProductSubscriptionTest") + .openOrThrowException("could not create test collection") + MappedApiCollectionEndpointsProvider.createApiCollectionEndpoint(collection.apiCollectionId, operationId).openOrThrowException("could not add endpoint") + collection.apiCollectionId + } + + def activeLimits(consumerId: String): ActiveRateLimitsJsonV600 = { + grant(resourceUser2.userId, "", CanGetRateLimits.toString) + val response = makeGetRequest((v6 / "management" / "consumers" / consumerId / "active-rate-limits").GET <@ (user2)) + response.code should equal(200) + response.body.extract[ActiveRateLimitsJsonV600] + } + + def scopesOf(consumerId: String): Set[(String, String)] = + Scope.scope.vend.getScopesByConsumerId(consumerId).getOrElse(Nil).map(s => (s.bankId, s.roleName)).toSet + + def callAsUser3() = makeGetRequest((v6 / "users" / "current").GET <@ (user3)) + + feature("Enforcement: active applies limits and scopes, suspended blocks, cancelled releases") { + scenario("Full life cycle on a partner consumer enrolled by the bank", ApiEndpoint1, ApiEndpoint8, VersionOfApi) { + Given("A product with limits 10/100 and a collection whose endpoint requires CanCreateApiProduct, BILLING_SYSTEM=manual") + val code = newProductCode() + createProduct(code, Some(createCollectionRequiring("OBPv6.0.0-createApiProduct"))) + setProductAttribute(code, "BILLING_SYSTEM", "manual") + val consumerId3 = Consumers.consumers.vend.getConsumerByConsumerKey(consumer3.key).map(_.consumerId.get).getOrElse("") + And("a scope granted by hand to that consumer beforehand") + Scope.scope.vend.addScope("", consumerId3, CanGetAnyUser.toString) + And("user2 is the bank admin") + grant(resourceUser2.userId, bankId, CanCreateApiProductSubscriptionAtOneBank.toString) + grant(resourceUser2.userId, bankId, CanUpdateApiProductSubscriptionStatusAtOneBank.toString) + + When("the bank enrols user3's consumer") + val created = subscribed(code, consumerId3, user2) + val id = created.api_product_subscription_id + Then("it is requested and nothing has been granted") + created.status should equal("requested") + created.rate_limiting_id shouldBe None + activeLimits(consumerId3).considered_rate_limit_ids shouldBe empty + scopesOf(consumerId3) should equal(Set(("", CanGetAnyUser.toString))) + callAsUser3().code should equal(200) + + When("the admin activates it") + val activated = putStatus(id, "active", user2).body.extract[ApiProductSubscriptionJsonV700] + Then("the consumer has the product's limits and the derived scope") + activated.status should equal("active") + activated.rate_limiting_id should not be None + val limits = activeLimits(consumerId3) + limits.considered_rate_limit_ids should equal(List(activated.rate_limiting_id.get)) + limits.active_per_second_rate_limit should equal(10L) + limits.active_per_minute_rate_limit should equal(100L) + limits.active_per_hour_rate_limit should equal(-1L) + scopesOf(consumerId3) should equal(Set(("", CanGetAnyUser.toString), (bankId, CanCreateApiProduct.toString))) + callAsUser3().code should equal(200) + + When("the admin suspends it") + val suspended = putStatus(id, "suspended", user2).body.extract[ApiProductSubscriptionJsonV700] + Then("the same row is now all zeros, the consumer is blocked, scopes are kept") + suspended.rate_limiting_id should equal(activated.rate_limiting_id) + val blockedLimits = activeLimits(consumerId3) + blockedLimits.active_per_second_rate_limit should equal(0L) + blockedLimits.active_per_month_rate_limit should equal(0L) + val blocked = callAsUser3() + blocked.code should equal(429) + errorOf(blocked) should include("blocked") + scopesOf(consumerId3) should contain((bankId, CanCreateApiProduct.toString)) + + When("the admin reinstates it") + val reinstated = putStatus(id, "active", user2).body.extract[ApiProductSubscriptionJsonV700] + Then("the product limits are back on the same row and the consumer can call again") + reinstated.rate_limiting_id should equal(activated.rate_limiting_id) + activeLimits(consumerId3).active_per_second_rate_limit should equal(10L) + callAsUser3().code should equal(200) + scopesOf(consumerId3) should equal(Set(("", CanGetAnyUser.toString), (bankId, CanCreateApiProduct.toString))) + + When("an admin deletes the subscription's rate limit row by hand and then suspends") + grant(resourceUser2.userId, "", CanDeleteRateLimits.toString) + makeDeleteRequest((v6 / "management" / "consumers" / consumerId3 / "consumer" / "rate-limits" / activated.rate_limiting_id.get).DELETE <@ (user2)).code should equal(204) + activeLimits(consumerId3).considered_rate_limit_ids shouldBe empty + val resuspended = putStatus(id, "suspended", user2).body.extract[ApiProductSubscriptionJsonV700] + Then("a fresh zero row is created and its id stored") + resuspended.rate_limiting_id should not be None + resuspended.rate_limiting_id should not equal activated.rate_limiting_id + activeLimits(consumerId3).considered_rate_limit_ids should equal(List(resuspended.rate_limiting_id.get)) + callAsUser3().code should equal(429) + + When("the admin cancels it") + val cancelled = putStatus(id, "cancelled", user2).body.extract[ApiProductSubscriptionJsonV700] + Then("the row and the derived scope are gone; the hand-granted scope survives") + cancelled.status should equal("cancelled") + cancelled.rate_limiting_id shouldBe None + activeLimits(consumerId3).considered_rate_limit_ids shouldBe empty + scopesOf(consumerId3) should equal(Set(("", CanGetAnyUser.toString))) + callAsUser3().code should equal(200) + } + + scenario("Self-service activation applies limits at once; a product without limits or collection grants nothing; manual limits are summed and survive cancel", ApiEndpoint1, ApiEndpoint4, VersionOfApi) { + Given("A consumer of user1 that already has a manual rate limit row of 5 per second") + grant(resourceUser2.userId, "", CanCreateRateLimits.toString) + val manual = makePostRequest((v6 / "management" / "consumers" / consumerId1 / "consumer" / "rate-limits").POST <@ (user2), + write(code.api.v6_0_0.CallLimitPostJsonV600( + from_date = new java.util.Date(System.currentTimeMillis() - 3600000L), to_date = new java.util.Date(System.currentTimeMillis() + 86400000L), + api_version = None, api_name = None, bank_id = None, + per_second_call_limit = "5", per_minute_call_limit = "-1", per_hour_call_limit = "-1", + per_day_call_limit = "-1", per_week_call_limit = "-1", per_month_call_limit = "-1"))) + manual.code should equal(201) + val manualId = manual.body.extract[code.api.v6_0_0.CallLimitJsonV600].rate_limiting_id + + When("user1 subscribes to an open product with limits 10/100 (no collection)") + val code1 = newProductCode() + createProduct(code1) + val active = subscribed(code1, consumerId1, user1) + Then("it is active with a row, and the limits are the sum of both rows") + active.status should equal("active") + active.rate_limiting_id should not be None + val summed = activeLimits(consumerId1) + summed.considered_rate_limit_ids.toSet should equal(Set(manualId, active.rate_limiting_id.get)) + summed.active_per_second_rate_limit should equal(15L) + summed.active_per_minute_rate_limit should equal(100L) + scopesOf(consumerId1) shouldBe empty + + When("user1 cancels it") + putMyStatus(active.api_product_subscription_id, "cancelled", user1).code should equal(200) + Then("only the manual row remains") + val remaining = activeLimits(consumerId1) + remaining.considered_rate_limit_ids should equal(List(manualId)) + remaining.active_per_second_rate_limit should equal(5L) + + When("user1 subscribes to a product with no limits at all") + val code2 = newProductCode() + grant(resourceUser1.userId, bankId, CanCreateApiProduct.toString) + val noLimits = PostPutApiProductJsonV600( + parent_api_product_code = None, name = "no limits", category = None, more_info_url = None, terms_and_conditions_url = None, + description = None, collection_id = None, monthly_subscription_currency = None, monthly_subscription_amount = None, + per_second_call_limit = None, per_minute_call_limit = None, per_hour_call_limit = None, + per_day_call_limit = None, per_week_call_limit = None, per_month_call_limit = None, tags = None) + makePostRequest((v6 / "banks" / bankId / "api-products" / code2).POST <@ (user1), write(noLimits)).code should equal(201) + val unlimited = subscribed(code2, consumerId1, user1) + Then("it is active but no row was created") + unlimited.status should equal("active") + unlimited.rate_limiting_id shouldBe None + activeLimits(consumerId1).considered_rate_limit_ids should equal(List(manualId)) + } + } +} From c583fe42134a487fe501b19ecc3c9d3ee363108a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 3 Sep 2026 08:48:37 +0200 Subject: [PATCH 5/5] Current Consumer Identity Endpoing. Auth Mode on Dynamic Entities --- .../dynamic/entity/Http4sDynamicEntity.scala | 85 ++++++--- .../entity/helper/DynamicEntityHelper.scala | 30 +++- .../scala/code/api/v6_0_0/Http4s600.scala | 5 + .../code/api/v6_0_0/JSONFactory6.0.0.scala | 14 +- .../scala/code/api/v7_0_0/Http4s700.scala | 44 +++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 17 ++ .../dynamicEntity/DynamicEntityProvider.scala | 45 ++++- .../MapppedDynamicEntityProvider.scala | 6 +- .../v6_0_0/DynamicEntityAuthModeTest.scala | 169 ++++++++++++++++++ .../v7_0_0/CurrentConsumerIdentityTest.scala | 42 +++++ 10 files changed, 417 insertions(+), 40 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerIdentityTest.scala diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index f3e9e82354..d506cfa76f 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -37,7 +37,7 @@ import code.api.util.APIUtil._ import code.api.util.ErrorMessages._ import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.http4s.{Http4sCallContextBuilder, Http4sRequestAttributes, RequestScopeConnection} -import code.api.util.{CallContext, CustomJsonFormats, NewStyle} +import code.api.util.{ApiRole, CallContext, CustomJsonFormats, NewStyle} import code.util.Helper import code.util.Helper.MdcLoggable import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -228,15 +228,43 @@ object Http4sDynamicEntity extends MdcLoggable { // write role is sufficient to PATCH that field alone. Returns the distinct role names the caller is missing. // For a personal entity that doesn't require a role, the entity update role on unrestricted fields is skipped, // but write-restricted fields are still gated by their field write role. + // ---- Auth mode (who may hold the entity's roles) ------------------------------------------ + // The entity definition's authMode decides whether the data endpoints accept a User's Entitlements, + // a Consumer's Scopes, either, or both. Personal ("my") endpoints and row-level (ACL) entities + // always need a User: their rows belong to one. + private def authModeOf(bankId: Option[String], entityName: String): EndpointAuthMode = + DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.endpointAuthMode).getOrElse(UserOnly) + + /** authenticatedAccess for user modes, applicationAccess (User optional, Consumer required) for application modes. */ + private def entityAccess(cc: CallContext, bankId: Option[String], entityName: String, isPersonalEntity: Boolean): Future[(Box[User], Option[CallContext])] = + if (isPersonalEntity || isRowLevel(bankId, entityName)) authenticatedAccess(cc) + else authModeOf(bankId, entityName) match { + case ApplicationOnly | UserOrApplication => applicationAccess(cc) + case _ => authenticatedAccess(cc) + } + + /** The entity's role, checked per the entity's auth mode (entitlements, scopes, either or both). */ + private def checkEntityRole(bankId: Option[String], entityName: String, boxUser: Box[User], role: ApiRole, callContext: Option[CallContext]): Future[Box[Unit]] = { + val bankIdStr = bankId.getOrElse("") + val userId = boxUser.map(_.userId).openOr("") + val consumerId = code.api.util.APIUtil.getConsumerPrimaryKey(callContext) + val errorMessage = if (bankIdStr.isEmpty) UserHasMissingRoles + role.toString else UserHasMissingRoles + role.toString + s" at Bank($bankIdStr)" + Helper.booleanToFuture(errorMessage, cc = callContext) { + code.api.util.APIUtil.handleAccessControlWithAuthMode(bankIdStr, userId, consumerId, List(role), authModeOf(bankId, entityName)) + } + } + private def missingPatchRoleNames( - bodyFieldNames: List[String], bankId: Option[String], entityName: String, userId: String, requireEntityRole: Boolean + bodyFieldNames: List[String], bankId: Option[String], entityName: String, userId: String, consumerId: String, requireEntityRole: Boolean ): List[String] = { val info = DynamicEntityHelper.definitionsMap.get((bankId, entityName)) // Only declared schema fields are meaningful (id/audit/unknown fields are ignored by the merge). val schemaFields = info.map(_.propertyNames).getOrElse(bodyFieldNames) val touched = bodyFieldNames.intersect(schemaFields) val writeRestricted = info.map(_.writeRestrictedFields).getOrElse(Nil).toSet - def has(role: code.api.util.ApiRole): Boolean = code.api.util.APIUtil.hasEntitlement(bankId.getOrElse(""), userId, role) + val authMode = authModeOf(bankId, entityName) + def has(role: code.api.util.ApiRole): Boolean = + code.api.util.APIUtil.handleAccessControlWithAuthMode(bankId.getOrElse(""), userId, consumerId, List(role), authMode) touched.flatMap { f => if (writeRestricted.contains(f)) { val role = DynamicEntityInfo.fieldWriteRole(entityName, f, bankId, info.flatMap(_.explicitWriteRole(f))) @@ -404,7 +432,7 @@ object Http4sDynamicEntity extends MdcLoggable { // Row ACL replaces the entity-update role; per-field write roles still apply (requireEntityRole = false). _ <- Helper.booleanToFuture(s"$UserHasMissingRoles update access on this row", 403, cc = callContext) { aclVend.allows(id, u.userId, DynamicDataAccessPermission.Update) } - missingRoles = missingPatchRoleNames(bodyObj.obj.map(_.name), bankId, entityName, u.userId, requireEntityRole = false) + missingRoles = missingPatchRoleNames(bodyObj.obj.map(_.name), bankId, entityName, u.userId, code.api.util.APIUtil.getConsumerPrimaryKey(callContext), requireEntityRole = false) _ <- Helper.booleanToFuture(s"$UserHasMissingRoles ${missingRoles.mkString(", ")}", 403, cc = callContext) { missingRoles.isEmpty } existing: Box[JValue] = dataVend.getCommunity(bankId, entityName, id).map(it => parse(it.dataJson)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } @@ -522,11 +550,12 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (Full(u), callContext) <- authenticatedAccess(callContext0) + (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) - else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) + else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) queryPlan <- if (isGetAll) buildQueryPlan(req, bankId, entityName, callContext) else Future.successful(QueryPlan.empty) decision = if (isGetAll) decideProjection(req, bankId, entityName, queryPlan) else UseInMemory @@ -535,10 +564,10 @@ object Http4sDynamicEntity extends MdcLoggable { _ <- if (decision == PendingProjection) Helper.booleanToFuture(DynamicEntityFieldNotYetQueryable, 409, cc = callContext) { false } else Future.successful(true) // Projection path: serve the list from SQL, skipping the fetch-all connector call. - projList <- if (decision == UseProjection) projectionList(entityName, bankId, Some(u.userId), isPersonalEntity, queryPlan).map(Option(_)) + projList <- if (decision == UseProjection) projectionList(entityName, bankId, userIdOpt, isPersonalEntity, queryPlan).map(Option(_)) else Future.successful(Option.empty[JArray]) (box, _) <- if (decision == UseProjection) Future.successful((net.liftweb.common.Empty: Box[JValue], callContext)) - else NewStyle.function.invokeDynamicConnector(operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + else NewStyle.function.invokeDynamicConnector(operation, entityName, None, Option(id).filter(StringUtils.isNotBlank), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) _ <- if (decision == UseProjection) Future.successful(true) else Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { box.isDefined } } yield { @@ -548,10 +577,10 @@ object Http4sDynamicEntity extends MdcLoggable { val legacyFiltered = filterDynamicObjects(resultList, queryParams(req)) applyQueryPlan(legacyFiltered, queryPlan, deIndexedFields(bankId, entityName)) } - wrapBankId(bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, Some(u.userId)))) + wrapBankId(bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, userIdOpt))) } else { val singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) - wrapBankId(bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, Some(u.userId)))) + wrapBankId(bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, userIdOpt))) } } } @@ -563,22 +592,23 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (Full(u), callContext) <- authenticatedAccess(callContext0) + (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) - else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canCreateRole(entityName, bankId), callContext) + else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canCreateRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) json <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")) } // Write-restricted fields are never set via POST; strip them before persisting. createJson = stripFields(json.asInstanceOf[JObject], writeRestrictedFieldsOf(bankId, entityName)) - (box, _) <- NewStyle.function.invokeDynamicConnector(CREATE, entityName, Some(createJson), None, bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (box, _) <- NewStyle.function.invokeDynamicConnector(CREATE, entityName, Some(createJson), None, bankId, None, userIdOpt, isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) // Row-level access: bootstrap the owner ACL row (R/U/D + Grant) so the creator can read, // edit, and share their own record with no role and no meta-admin hop (§4 / §8.1). _ = if (isRowLevel(bankId, entityName)) (singleObject \ DynamicEntityHelper.createEntityId(entityName)) match { case JString(rid) => - aclVend.grant(rid, u.userId, canRead = true, canUpdate = true, canDelete = true, canGrant = true, entityName, bankId, grantedBy = u.userId) + userIdOpt.foreach(uid => aclVend.grant(rid, uid, canRead = true, canUpdate = true, canDelete = true, canGrant = true, entityName, bankId, grantedBy = uid)) case _ => } } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) @@ -595,18 +625,19 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (Full(u), callContext) <- authenticatedAccess(callContext0) + (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) - else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canUpdateRole(entityName, bankId), callContext) + else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canUpdateRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) json <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")) } - (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } // Write-restricted fields are not updated via PUT; preserve their existing values. updateJson = preserveRestrictedOnPut(json.asInstanceOf[JObject], existing.asInstanceOf[Box[JValue]], writeRestrictedFieldsOf(bankId, entityName)) - (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(updateJson), Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(updateJson), Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box, entityName) } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) } @@ -622,7 +653,8 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (Full(u), callContext) <- authenticatedAccess(callContext0) + (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) _ <- failIf(afterIntercept(callContext, operationId), callContext) @@ -632,13 +664,13 @@ object Http4sDynamicEntity extends MdcLoggable { // write role if write-restricted, otherwise the entity update role. No blanket entity-update precondition. // For a personal entity without a required role, the entity role on unrestricted fields is skipped. requireEntityRole = !(isPersonalEntity && !personalRequiresRole) - missingRoles = missingPatchRoleNames(bodyObj.obj.map(_.name), bankId, entityName, u.userId, requireEntityRole) + missingRoles = missingPatchRoleNames(bodyObj.obj.map(_.name), bankId, entityName, boxUser.map(_.userId).openOr(""), code.api.util.APIUtil.getConsumerPrimaryKey(callContext), requireEntityRole) _ <- Helper.booleanToFuture(s"$UserHasMissingRoles ${missingRoles.mkString(", ")}", 403, cc = callContext) { missingRoles.isEmpty } - (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } // PATCH = partial update: merge incoming fields over the existing record. mergedJson = mergePatch(DynamicEntityHelper.definitionsMap.get((bankId, entityName)), existing.asInstanceOf[Box[JValue]], bodyObj) - (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(mergedJson), Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(mergedJson), Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box, entityName) } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) } @@ -654,15 +686,16 @@ object Http4sDynamicEntity extends MdcLoggable { val operationId = callContext0.operationId.orNull for { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) - (Full(u), callContext) <- authenticatedAccess(callContext0) + (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) + userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) - else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canDeleteRole(entityName, bankId), callContext) + else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canDeleteRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) - (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } - (box, _) <- NewStyle.function.invokeDynamicConnector(DELETE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + (box, _) <- NewStyle.function.invokeDynamicConnector(DELETE, entityName, None, Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) _: JBool = unboxResult(box.asInstanceOf[Box[JBool]], entityName) } yield JObject(Nil) } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 29845859a9..ccecb90fe7 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -148,7 +148,7 @@ object DynamicEntityHelper { private val implementedInApiVersion = ApiVersion.v4_0_0 // (Some(BankId), EntityName, DynamicEntityInfo) - def definitionsMap: Map[(Option[String], String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((it.bankId, it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess))).toMap + def definitionsMap: Map[(Option[String], String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((it.bankId, it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess, it.authMode))).toMap def dynamicEntityRoles: List[String] = NewStyle.function.getDynamicEntities(None, true).flatMap { dEntity => val baseRoles = DynamicEntityInfo.roleNames(dEntity.entityName, dEntity.bankId) @@ -274,7 +274,8 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canGetRole)), - createdByBankId= dynamicEntityInfo.bankId + createdByBankId= dynamicEntityInfo.bankId, + authMode = dynamicEntityInfo.endpointAuthMode ) resourceDocs += (DynamicEntityOperation.GET_ONE, splitNameWithBankId) -> ResourceDoc( @@ -301,7 +302,8 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canGetRole)), - createdByBankId= dynamicEntityInfo.bankId + createdByBankId= dynamicEntityInfo.bankId, + authMode = dynamicEntityInfo.endpointAuthMode ) resourceDocs += (DynamicEntityOperation.CREATE, splitNameWithBankId) -> ResourceDoc( @@ -330,7 +332,8 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canCreateRole)), - createdByBankId= dynamicEntityInfo.bankId + createdByBankId= dynamicEntityInfo.bankId, + authMode = dynamicEntityInfo.endpointAuthMode ) resourceDocs += (DynamicEntityOperation.UPDATE, splitNameWithBankId) -> ResourceDoc( @@ -359,7 +362,8 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canUpdateRole)), - createdByBankId= dynamicEntityInfo.bankId + createdByBankId= dynamicEntityInfo.bankId, + authMode = dynamicEntityInfo.endpointAuthMode ) resourceDocs += (DynamicEntityOperation.PATCH, splitNameWithBankId) -> ResourceDoc( @@ -395,7 +399,8 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canUpdateRole)), - createdByBankId= dynamicEntityInfo.bankId + createdByBankId= dynamicEntityInfo.bankId, + authMode = dynamicEntityInfo.endpointAuthMode ) resourceDocs += (DynamicEntityOperation.DELETE, splitNameWithBankId) -> ResourceDoc( @@ -421,7 +426,8 @@ object DynamicEntityHelper { ), List(apiTag, apiTagDynamicEntity, apiTagDynamic), Some(List(dynamicEntityInfo.canDeleteRole)), - createdByBankId= dynamicEntityInfo.bankId + createdByBankId= dynamicEntityInfo.bankId, + authMode = dynamicEntityInfo.endpointAuthMode ) if(hasPersonalEntity){ //only hasPersonalEntity == true, then create the myEndpoints @@ -742,7 +748,15 @@ object DynamicEntityHelper { |""".stripMargin } -case class DynamicEntityInfo(definition: String, entityName: String, bankId: Option[String], hasPersonalEntity: Boolean, hasPublicAccess: Boolean = false, hasCommunityAccess: Boolean = false, personalRequiresRole: Boolean = false, useRowLevelAccess: Boolean = false) { +case class DynamicEntityInfo(definition: String, entityName: String, bankId: Option[String], hasPersonalEntity: Boolean, hasPublicAccess: Boolean = false, hasCommunityAccess: Boolean = false, personalRequiresRole: Boolean = false, useRowLevelAccess: Boolean = false, authMode: String = code.dynamicEntity.DynamicEntityAuthMode.default) { + + /** The entity's auth mode as the framework type; unknown or empty values read as UserOnly. */ + val endpointAuthMode: code.api.util.APIUtil.EndpointAuthMode = authMode match { + case code.dynamicEntity.DynamicEntityAuthMode.ApplicationOnly => code.api.util.APIUtil.ApplicationOnly + case code.dynamicEntity.DynamicEntityAuthMode.UserOrApplication => code.api.util.APIUtil.UserOrApplication + case code.dynamicEntity.DynamicEntityAuthMode.UserAndApplication => code.api.util.APIUtil.UserAndApplication + case _ => code.api.util.APIUtil.UserOnly + } import com.openbankproject.commons.util.json import code.api.dynamic.entity.query.FieldSpec 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 d55caeb6f2..3676aead1d 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 @@ -7023,6 +7023,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. + |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserOrApplication` (either) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). | @@ -7092,6 +7093,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. + |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserOrApplication` (either) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). | @@ -7163,6 +7165,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. + |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserOrApplication` (either) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). | @@ -7223,6 +7226,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. + |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserOrApplication` (either) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). | @@ -7289,6 +7293,7 @@ object Http4s600 { |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. + |* Set `auth_mode` to say who may hold the roles that guard the entity's data endpoints: `UserOnly` (default, the User's Entitlements), `ApplicationOnly` (the Consumer's Scopes), `UserOrApplication` (either) or `UserAndApplication` (both). Personal (`/my/`) endpoints always require a User. An entity with `has_personal_entity` cannot be `ApplicationOnly`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). | 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 0d126d57bf..8e539b5ace 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 @@ -997,6 +997,7 @@ case class DynamicEntityDefinitionJsonV600( has_community_access: Boolean = false, personal_requires_role: Boolean = false, use_row_level_access: Boolean = false, + auth_mode: String = "UserOnly", schema: org.json4s.JsonAST.JObject, _links: Option[DynamicEntityLinksJsonV600] = None ) @@ -1016,6 +1017,7 @@ case class DynamicEntityDefinitionWithCountJsonV600( has_community_access: Boolean = false, personal_requires_role: Boolean = false, use_row_level_access: Boolean = false, + auth_mode: String = "UserOnly", schema: org.json4s.JsonAST.JObject, record_count: Long, _links: Option[DynamicEntityLinksJsonV600] = None @@ -1033,6 +1035,7 @@ case class CreateDynamicEntityRequestJsonV600( has_community_access: Option[Boolean] = None, // defaults to false if not provided personal_requires_role: Option[Boolean] = None, // defaults to false if not provided use_row_level_access: Option[Boolean] = None, // defaults to false if not provided + auth_mode: Option[String] = None, // UserOnly | ApplicationOnly | UserOrApplication | UserAndApplication; defaults to UserOnly schema: org.json4s.JsonAST.JObject ) @@ -1044,6 +1047,7 @@ case class UpdateDynamicEntityRequestJsonV600( has_community_access: Option[Boolean] = None, personal_requires_role: Option[Boolean] = None, use_row_level_access: Option[Boolean] = None, + auth_mode: Option[String] = None, schema: org.json4s.JsonAST.JObject ) @@ -2627,7 +2631,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) // Validate that the dynamic key matches entity_name - val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess") + val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess", "authMode") val dynamicKeyName = fullJson.obj.find(f => !knownFlagFields.contains(f.name)).map(_.name) if (dynamicKeyName.exists(_ != entity.entityName)) { throw new IllegalStateException( @@ -2651,6 +2655,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { has_community_access = entity.hasCommunityAccess, personal_requires_role = entity.personalRequiresRole, use_row_level_access = entity.useRowLevelAccess, + auth_mode = entity.authMode, schema = schemaObj, _links = Some(links) ) @@ -2674,7 +2679,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) // Validate that the dynamic key matches entity_name - val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess") + val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess", "authMode") val dynamicKeyName = fullJson.obj.find(f => !knownFlagFields.contains(f.name)).map(_.name) if (dynamicKeyName.exists(_ != entity.entityName)) { throw new IllegalStateException( @@ -2698,6 +2703,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { has_community_access = entity.hasCommunityAccess, personal_requires_role = entity.personalRequiresRole, use_row_level_access = entity.useRowLevelAccess, + auth_mode = entity.authMode, schema = schema, record_count = recordCount, _links = Some(links) @@ -2730,6 +2736,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val hasCommunityAccess = request.has_community_access.getOrElse(false) val personalRequiresRole = request.personal_requires_role.getOrElse(false) val useRowLevelAccess = request.use_row_level_access.getOrElse(false) + val authMode = code.dynamicEntity.DynamicEntityAuthMode.normalise(request.auth_mode.getOrElse("")) // Build the internal format: entity name as dynamic key + flags JObject( @@ -2739,6 +2746,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { JField("hasCommunityAccess", JBool(hasCommunityAccess)) :: JField("personalRequiresRole", JBool(personalRequiresRole)) :: JField("useRowLevelAccess", JBool(useRowLevelAccess)) :: + JField("authMode", JString(authMode)) :: Nil ) } @@ -2751,6 +2759,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val hasCommunityAccess = request.has_community_access.getOrElse(false) val personalRequiresRole = request.personal_requires_role.getOrElse(false) val useRowLevelAccess = request.use_row_level_access.getOrElse(false) + val authMode = code.dynamicEntity.DynamicEntityAuthMode.normalise(request.auth_mode.getOrElse("")) // Build the internal format: entity name as dynamic key + flags JObject( @@ -2760,6 +2769,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { JField("hasCommunityAccess", JBool(hasCommunityAccess)) :: JField("personalRequiresRole", JBool(personalRequiresRole)) :: JField("useRowLevelAccess", JBool(useRowLevelAccess)) :: + JField("authMode", JString(authMode)) :: 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 517c4b148a..680321cad0 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 @@ -730,6 +730,50 @@ object Http4s700 { http4sPartialFunction = Some(getConsentsConfig) ) + // Route: GET /obp/v7.0.0/consumers/current/identity + // Answers "which Consumer am I?" for whoever is calling: a logged-in User (via their Consumer) or an + // Application on its own (client_credentials or a Consumer-Key). No Role: a caller may always learn + // its own identity. Unlike GET /obp/v6.0.0/consumers/current it carries no rate limits or call counters. + val getCurrentConsumerIdentity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumers" / "current" / "identity" => + EndpointHelpers.executeFuture(req) { + implicit val cc: CallContext = req.callContext + for { + consumer <- Future(cc.consumer match { + case Full(c) => Full(c) + case _ => net.liftweb.common.Empty + }).map(unboxFullOrFail(_, Some(cc), ApplicationNotIdentified, 401)) + } yield JSONFactory700.createCurrentConsumerIdentityJsonV700(consumer) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getCurrentConsumerIdentity), + "GET", + "/consumers/current/identity", + "Get Current Consumer Identity", + s"""Returns the identity of the Consumer making this call: `consumer_id` and `consumer_name`. + | + |Nothing else is returned: no description, no key, no rate limits, no call counters. For those, see Get Current Consumer (v6.0.0), + |which requires a Role. + | + |No Role is required. The caller must be identifiable as a Consumer, either through a logged-in User (whose + |Consumer this is) or as an Application on its own (OAuth2 client credentials, or a Consumer Key). + |A call with no credentials gets ${ApplicationNotIdentified} + | + |Use it from a service (for example the Portal or the API Manager) to show which Consumer it is configured + |with, or to check that its client id matches a registered Consumer. + |""".stripMargin, + EmptyBody, + JSONFactory700.currentConsumerIdentityJsonV700Example, + List(ApplicationNotIdentified, UnknownError), + apiTagConsumer :: apiTagApi :: Nil, + None, + authMode = UserOrApplication, + http4sPartialFunction = Some(getCurrentConsumerIdentity) + ) + // Route: GET /obp/v7.0.0/public/password-config // Anonymous: clients need the policy before they hold credentials, to validate // a proposed password locally during signup or password reset. The /public 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 0700b61261..18ed109f68 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 @@ -1693,6 +1693,23 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { regex: String ) + /** The calling Consumer's identity and nothing else: no description, no limits, no counters, no key. */ + case class CurrentConsumerIdentityJsonV700( + consumer_id: String, + consumer_name: String + ) + + def createCurrentConsumerIdentityJsonV700(consumer: code.model.Consumer): CurrentConsumerIdentityJsonV700 = + CurrentConsumerIdentityJsonV700( + consumer_id = consumer.consumerId.get, + consumer_name = Option(consumer.name.get).getOrElse("") + ) + + lazy val currentConsumerIdentityJsonV700Example = CurrentConsumerIdentityJsonV700( + consumer_id = ExampleValue.consumerIdExample.value, + consumer_name = "OBP Portal" + ) + case class PasswordPolicyJsonV700( description: String, min_length: Int, diff --git a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala index 0c19f0c16a..21f591ecab 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala @@ -43,6 +43,13 @@ trait DynamicEntityT { def personalRequiresRole: Boolean def useRowLevelAccess: Boolean + /** + * Who may hold the roles guarding this entity's data endpoints (see [[DynamicEntityAuthMode]]): + * the User's Entitlements, the Consumer's Scopes, either, or both. Personal ("my") endpoints + * always require a User regardless of this value. + */ + def authMode: String + /** * Add Option(bank_id) to Dynamic Entity. * Then we should treat the two cases very separately. @@ -415,6 +422,22 @@ object ReferenceType extends MdcLoggable { } } +/** + * Auth mode of a Dynamic Entity's data endpoints, stored as its name. Mirrors + * [[code.api.util.APIUtil.EndpointAuthMode]] without dragging APIUtil into the model. + */ +object DynamicEntityAuthMode { + val UserOnly = "UserOnly" + val ApplicationOnly = "ApplicationOnly" + val UserOrApplication = "UserOrApplication" + val UserAndApplication = "UserAndApplication" + val all: List[String] = List(UserOnly, ApplicationOnly, UserOrApplication, UserAndApplication) + val default: String = UserOnly + def isValid(value: String): Boolean = all.contains(value) + /** null / empty (rows created before the column existed) read as the default. */ + def normalise(value: String): String = Option(value).map(_.trim).filter(_.nonEmpty).getOrElse(default) +} + case class DynamicEntityCommons(entityName: String, metadataJson: String, dynamicEntityId: Option[String] = None, @@ -424,7 +447,8 @@ case class DynamicEntityCommons(entityName: String, hasPublicAccess: Boolean = false, hasCommunityAccess: Boolean = false, personalRequiresRole: Boolean = false, - useRowLevelAccess: Boolean = false + useRowLevelAccess: Boolean = false, + authMode: String = DynamicEntityAuthMode.default ) extends DynamicEntityT with JsonFieldReName object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommons] { @@ -469,7 +493,7 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo val fields = jsonObject.obj // Known flag field names at the root level (not the entity definition itself) - val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess") + val knownFlagFields = Set("hasPersonalEntity", "hasPublicAccess", "hasCommunityAccess", "personalRequiresRole", "useRowLevelAccess", "authMode") // validate root object fields val fieldsSize = fields.size @@ -486,6 +510,12 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo val personalRequiresRoleValue: Boolean = fields.filter(_.name == "personalRequiresRole").map(_.value.asInstanceOf[JBool].values).headOption.getOrElse(false) // Determine the value of useRowLevelAccess; use the field's boolean value if provided, otherwise default to false val useRowLevelAccessValue: Boolean = fields.filter(_.name == "useRowLevelAccess").map(_.value.asInstanceOf[JBool].values).headOption.getOrElse(false) + // Determine the value of authMode; a string naming an EndpointAuthMode, default UserOnly + val authModeValue: String = fields.filter(_.name == "authMode").map(_.value).headOption match { + case Some(JString(v)) => DynamicEntityAuthMode.normalise(v) + case Some(JNull) | Some(JNothing) | None => DynamicEntityAuthMode.default + case Some(_) => "" // wrong JSON type: fails the validity check below + } checkFormat(fields.nonEmpty, s"$DynamicEntityInstanceValidateFail The Json root object should have a single entity, but current have none.") checkFormat(entityFields.size == 1, s"$DynamicEntityInstanceValidateFail The Json root object should have exactly one entity field (plus optional flags: ${knownFlagFields.mkString(", ")}), but current root objects: ${fields.map(_.name).mkString(", ")}") @@ -493,6 +523,15 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo flagFields.forall(f => knownFlagFields.contains(f.name)), s"$DynamicEntityInstanceValidateFail Unknown flag fields. Allowed flags: ${knownFlagFields.mkString(", ")}. Current root objects: ${fields.map(_.name).mkString(", ")}" ) + checkFormat( + DynamicEntityAuthMode.isValid(authModeValue), + s"$DynamicEntityInstanceValidateFail authMode must be one of ${DynamicEntityAuthMode.all.mkString(", ")}." + ) + // An application-only call has no User, so an entity whose rows are personal ("my") cannot be ApplicationOnly. + checkFormat( + !(hasPersonalEntityValue && authModeValue == DynamicEntityAuthMode.ApplicationOnly), + s"$DynamicEntityInstanceValidateFail authMode ${DynamicEntityAuthMode.ApplicationOnly} cannot be combined with hasPersonalEntity." + ) // §8.3: useRowLevelAccess is a master switch — mutually exclusive with public/community access. checkFormat( !(useRowLevelAccessValue && (hasPublicAccessValue || hasCommunityAccessValue)), @@ -649,7 +688,7 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo } }) - DynamicEntityCommons(entityName, compactRender(jsonObject), dynamicEntityId, userId, bankId, hasPersonalEntityValue, hasPublicAccessValue, hasCommunityAccessValue, personalRequiresRoleValue, useRowLevelAccessValue) + DynamicEntityCommons(entityName, compactRender(jsonObject), dynamicEntityId, userId, bankId, hasPersonalEntityValue, hasPublicAccessValue, hasCommunityAccessValue, personalRequiresRoleValue, useRowLevelAccessValue, authModeValue) } // `reference` is an internal query-layer type (see DynamicEntityFieldType.reference), never declared diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala index ed37d8a6c3..a1e2ab80bb 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala @@ -89,6 +89,7 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson .HasCommunityAccess(dynamicEntity.hasCommunityAccess) .PersonalRequiresRole(dynamicEntity.personalRequiresRole) .UseRowLevelAccess(dynamicEntity.useRowLevelAccess) + .AuthMode(DynamicEntityAuthMode.normalise(dynamicEntity.authMode)) .saveMe() // DE_indexing: provision/refresh the projection for this definition's indexed scalar fields. // Guarded by projectionEnabled (default off); best-effort (a failure leaves the definition saved @@ -98,7 +99,7 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson try { val info = code.api.dynamic.entity.helper.DynamicEntityInfo( dynamicEntity.metadataJson, dynamicEntity.entityName, dynamicEntity.bankId, - dynamicEntity.hasPersonalEntity, dynamicEntity.hasPublicAccess, dynamicEntity.hasCommunityAccess, dynamicEntity.personalRequiresRole, dynamicEntity.useRowLevelAccess) + dynamicEntity.hasPersonalEntity, dynamicEntity.hasPublicAccess, dynamicEntity.hasCommunityAccess, dynamicEntity.personalRequiresRole, dynamicEntity.useRowLevelAccess, dynamicEntity.authMode) val scalar = code.api.dynamic.entity.projection.ProjectionProvisioner.scalarFieldsOf(info.indexedFields) if (scalar.nonEmpty) code.api.dynamic.entity.projection.ProjectionProvisioner @@ -144,6 +145,8 @@ class DynamicEntity extends DynamicEntityT with LongKeyedMapper[DynamicEntity] w object HasCommunityAccess extends MappedBoolean(this) object PersonalRequiresRole extends MappedBoolean(this) object UseRowLevelAccess extends MappedBoolean(this) + // Name of an EndpointAuthMode; null for rows created before the column existed (read as UserOnly). + object AuthMode extends MappedString(this, 32) override def dynamicEntityId: Option[String] = Option(DynamicEntityId.get) override def entityName: String = EntityName.get @@ -155,6 +158,7 @@ class DynamicEntity extends DynamicEntityT with LongKeyedMapper[DynamicEntity] w override def hasCommunityAccess: Boolean = HasCommunityAccess.get override def personalRequiresRole: Boolean = PersonalRequiresRole.get override def useRowLevelAccess: Boolean = UseRowLevelAccess.get + override def authMode: String = DynamicEntityAuthMode.normalise(AuthMode.get) } object DynamicEntity extends DynamicEntity with LongKeyedMetaMapper[DynamicEntity] { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala new file mode 100644 index 0000000000..4fbbe24faf --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala @@ -0,0 +1,169 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import com.openbankproject.commons.model.ErrorMessage +import code.api.util.ErrorMessages.{DynamicEntityInstanceValidateFail, UserHasMissingRoles} +import code.entitlement.Entitlement +import code.scope.Scope +import com.openbankproject.commons.util.ApiVersion +import com.openbankproject.commons.util.JsonAliases._ +import org.json4s.JsonDSL._ +import org.json4s._ +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +/** + * auth_mode on a Dynamic Entity decides who may hold the roles guarding its data endpoints: + * the User's Entitlements (UserOnly, the default), the Consumer's Scopes (ApplicationOnly), + * either (UserOrApplication) or both (UserAndApplication). + */ +class DynamicEntityAuthModeTest extends V600ServerSetup { + // user1 defines the entities (and may hold broad roles in the shared test DB); + // user2, who starts with none of the entity roles and signs with testConsumer2, is the subject + // of the data-endpoint checks, so Scopes go on testConsumer2. + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + + def simpleSchema: JValue = parse( + """ + |{ + | "description": "Test entity for auth mode testing.", + | "required": ["name"], + | "properties": { + | "name": { "type": "string", "maxLength": 40, "minLength": 1, "example": "Test" } + | } + |} + """.stripMargin) + + def entityJson(name: String, authMode: Option[String], personal: Boolean = false): JValue = { + val base: JObject = ("entity_name" -> name) ~ ("has_personal_entity" -> personal) ~ ("schema" -> simpleSchema) + authMode.map(m => base ~ ("auth_mode" -> m)).getOrElse(base) + } + + def createSystemEntity(json: JValue): (Int, JValue) = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + val response = makePostRequest((v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1), write(json)) + (response.code, response.body) + } + + def deleteSystemEntity(dynamicEntityId: String): Unit = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + makeDeleteRequest((v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1)) + } + + def getRoleName(entityName: String): String = s"CanGetDynamicEntity_System$entityName" + def createRoleName(entityName: String): String = s"CanCreateDynamicEntity_System$entityName" + + feature("auth_mode on the entity definition") { + + scenario("defaults to UserOnly and is returned on the definition", VersionOfApi) { + val (code, body) = createSystemEntity(entityJson("am_default", None)) + code should equal(201) + (body \ "auth_mode").extract[String] should equal("UserOnly") + deleteSystemEntity((body \ "dynamic_entity_id").extract[String]) + } + + scenario("accepts UserOrApplication and returns it", VersionOfApi) { + val (code, body) = createSystemEntity(entityJson("am_either", Some("UserOrApplication"))) + code should equal(201) + (body \ "auth_mode").extract[String] should equal("UserOrApplication") + deleteSystemEntity((body \ "dynamic_entity_id").extract[String]) + } + + scenario("rejects an unknown value", VersionOfApi) { + val (code, body) = createSystemEntity(entityJson("am_bad", Some("Nobody"))) + code should equal(400) + (body \ "message").extract[String] should include(DynamicEntityInstanceValidateFail) + } + + scenario("rejects ApplicationOnly on a personal entity", VersionOfApi) { + val (code, body) = createSystemEntity(entityJson("am_personal_app", Some("ApplicationOnly"), personal = true)) + code should equal(400) + (body \ "message").extract[String] should include("ApplicationOnly") + } + } + + feature("auth_mode on the entity's data endpoints") { + + scenario("UserOnly (default): a Consumer Scope alone is not enough", VersionOfApi) { + val entityName = "am_useronly_data" + val (code, body) = createSystemEntity(entityJson(entityName, None)) + code should equal(201) + val entityId = (body \ "dynamic_entity_id").extract[String] + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + try { + val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } finally { + Scope.scope.vend.deleteScope(scope) + deleteSystemEntity(entityId) + } + } + + scenario("UserOrApplication: a Consumer Scope alone is enough", VersionOfApi) { + val entityName = "am_either_data" + val (code, body) = createSystemEntity(entityJson(entityName, Some("UserOrApplication"))) + code should equal(201) + val entityId = (body \ "dynamic_entity_id").extract[String] + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + try { + val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) + response.code should equal(200) + } finally { + Scope.scope.vend.deleteScope(scope) + deleteSystemEntity(entityId) + } + } + + scenario("UserOrApplication: a User Entitlement alone is still enough", VersionOfApi) { + val entityName = "am_either_user" + val (code, body) = createSystemEntity(entityJson(entityName, Some("UserOrApplication"))) + code should equal(201) + val entityId = (body \ "dynamic_entity_id").extract[String] + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, getRoleName(entityName)) + try { + val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) + response.code should equal(200) + } finally { + deleteSystemEntity(entityId) + } + } + + scenario("UserOrApplication: the Scope only covers the role it names (Get, not Create)", VersionOfApi) { + val entityName = "am_either_scoped" + val (code, body) = createSystemEntity(entityJson(entityName, Some("UserOrApplication"))) + code should equal(201) + val entityId = (body \ "dynamic_entity_id").extract[String] + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + try { + val response = makePostRequest((dynamicEntity_Request / entityName).POST <@(user2), write(("name" -> "x"): JObject)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(createRoleName(entityName)) + } finally { + Scope.scope.vend.deleteScope(scope) + deleteSystemEntity(entityId) + } + } + + scenario("UserAndApplication: needs both the Entitlement and the Scope", VersionOfApi) { + val entityName = "am_both_data" + val (code, body) = createSystemEntity(entityJson(entityName, Some("UserAndApplication"))) + code should equal(201) + val entityId = (body \ "dynamic_entity_id").extract[String] + try { + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, getRoleName(entityName)) + makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(403) + val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + try { + makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(200) + } finally { + Scope.scope.vend.deleteScope(scope) + } + } finally { + deleteSystemEntity(entityId) + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerIdentityTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerIdentityTest.scala new file mode 100644 index 0000000000..e1303783fa --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerIdentityTest.scala @@ -0,0 +1,42 @@ +package code.api.v7_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ErrorMessages.ApplicationNotIdentified +import code.api.v6_0_0.V600ServerSetup +import code.api.v7_0_0.JSONFactory700.CurrentConsumerIdentityJsonV700 +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** GET /obp/v7.0.0/consumers/current/identity: the caller's own Consumer, no Role, nothing sensitive. */ +class CurrentConsumerIdentityTest extends V600ServerSetup { + def v7_0_0_Request = baseRequest / "obp" / "v7.0.0" + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object ApiEndpoint1 extends Tag("getCurrentConsumerIdentity") + + feature(s"test $ApiEndpoint1 version $VersionOfApi") { + scenario("Without any credentials the application cannot be identified", ApiEndpoint1, VersionOfApi) { + val response = makeGetRequest((v7_0_0_Request / "consumers" / "current" / "identity").GET) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) + } + + scenario("A logged-in user gets the identity of the Consumer they called with, and nothing else", ApiEndpoint1, VersionOfApi) { + val response = makeGetRequest((v7_0_0_Request / "consumers" / "current" / "identity").GET <@ (user1)) + Then("We should get a 200") + response.code should equal(200) + val identity = response.body.extract[CurrentConsumerIdentityJsonV700] + identity.consumer_id should equal(testConsumer.consumerId.get) + identity.consumer_name should equal(testConsumer.name.get) + And("the body carries only the two identity fields") + response.body.asInstanceOf[org.json4s.JObject].obj.map(_._1).toSet should equal(Set("consumer_id", "consumer_name")) + } + + scenario("A different user sees their own Consumer", ApiEndpoint1, VersionOfApi) { + val response = makeGetRequest((v7_0_0_Request / "consumers" / "current" / "identity").GET <@ (user2)) + response.code should equal(200) + response.body.extract[CurrentConsumerIdentityJsonV700].consumer_id should equal(testConsumer2.consumerId.get) + } + } +}