From add7155501499b4cfbe9c8f1c383d279dde61695 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 13 Mar 2026 13:39:50 +0100 Subject: [PATCH 01/11] Acronyms in status page --- obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index 0f9371d549..0f93764a9d 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -10,12 +10,14 @@ object StatusPage { private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs + private val acronyms = Set("obp", "api", "mcp") + private def humanName(key: String): String = key.stripPrefix("public_") .stripSuffix("_url") .replace("_", " ") .split(" ") - .map(_.capitalize) + .map(w => if (acronyms.contains(w.toLowerCase)) w.toUpperCase else w.capitalize) .mkString(" ") private def prefersJson(req: Request[IO]): Boolean = From 1b7a7eb0ae7fad497fc1049d9cb8b609da25348f Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 13 Mar 2026 13:49:41 +0100 Subject: [PATCH 02/11] Added connector traces to intro sys doc.md --- .../docs/introductory_system_documentation.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 5a7b4a6e64..487a0ae010 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -1294,6 +1294,56 @@ Adapters listen to message queues or remote calls, parse incoming messages accor - Adapter in Go for high-performance transaction processing - Adapter in Scala for Akka-based distributed systems +**Testing Adapters with Connector Endpoints:** + +When building an adapter, you can use the `connector.name.export.as.endpoints` props setting to expose all of a connector's internal methods as REST endpoints. This is very useful during adapter development because it allows you to call individual connector methods directly (e.g. `getBank`, `getBankAccount`) and inspect their request/response payloads without needing to go through the full API layer. + +When this property is set, OBP-API registers endpoints at `/obp/connector/{methodName}` which accept JSON request bodies matching the corresponding OutBound DTO and return JSON responses matching the InBound DTO. This lets you test each connector method in isolation. + +```properties +# Export a connector's methods as REST endpoints for development/testing +# Set this to the connector name you are building an adapter for: +connector.name.export.as.endpoints=rabbitmq_vOct2024 +``` + +**Validation rules:** +- If `connector=star`, the value must match one of the connectors listed in `starConnector_supported_types` +- If `connector=mapped`, the value can be `mapped` +- Otherwise, the value must match the `connector` props value (e.g. if `connector=rest_vMar2019`, set `connector.name.export.as.endpoints=rest_vMar2019`) + +**Access control:** Calling these endpoints requires the `CanGetConnectorEndpoint` entitlement. + +**Debugging Adapters with Connector Traces:** + +Connector traces capture the full outbound (request) and inbound (response) messages for every connector call. This is invaluable when building an adapter because you can see exactly what OBP-API sent to your adapter and what it received back, making it easy to diagnose serialization issues, missing fields, or unexpected responses. + +Enable connector traces with: + +```properties +write_connector_trace=true +``` + +Each trace records: +- **correlationId** — links the trace to the originating API request +- **connectorName** — which connector was used (e.g. `rabbitmq_vOct2024`) +- **functionName** — the connector method called (e.g. `getBank`, `getBankAccount`) +- **bankId** — the bank identifier, if applicable +- **outboundMessage** — full serialized request parameters sent to the adapter +- **inboundMessage** — full serialized response received from the adapter +- **duration** — call duration in milliseconds +- **isSuccessful** — whether the call succeeded +- **userId**, **httpVerb**, **url** — context about the originating API request + +Traces can be retrieved via the API: + +``` +GET /obp/v6.0.0/management/connector/traces +``` + +This endpoint supports filtering by `connector_name`, `function_name`, `correlation_id`, `bank_id`, `user_id`, `from_date`, `to_date`, and pagination with `limit` and `offset`. It requires the `CanGetConnectorTrace` entitlement. + +There is also a **Connector Traces** page in **API Manager** which provides a UI for browsing and filtering connector traces. + --- ### 3.13 Message Docs From 0211e2a8cc8bb8280a6b5181148e14ba5d1be76c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 13 Mar 2026 14:44:59 +0100 Subject: [PATCH 03/11] Adding IS NOT NULL to provider_ --- obp-api/src/main/scala/code/api/util/DoobieQueries.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/DoobieQueries.scala b/obp-api/src/main/scala/code/api/util/DoobieQueries.scala index 9046b8d350..101f6cb623 100644 --- a/obp-api/src/main/scala/code/api/util/DoobieQueries.scala +++ b/obp-api/src/main/scala/code/api/util/DoobieQueries.scala @@ -20,7 +20,7 @@ object DoobieQueries { */ def getDistinctProviders: List[String] = { val query: ConnectionIO[List[String]] = - sql"""SELECT DISTINCT provider_ FROM resourceuser ORDER BY provider_""" + sql"""SELECT DISTINCT provider_ FROM resourceuser WHERE provider_ IS NOT NULL ORDER BY provider_""" .query[String] .to[List] From ec7fdf41726d8eae0441ef3d128f6151f9e4a6a1 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 13 Mar 2026 22:55:32 +0100 Subject: [PATCH 04/11] Counterparty Attributes --- .../main/scala/bootstrap/liftweb/Boot.scala | 2 + .../SwaggerDefinitionsJSON.scala | 22 +- .../main/scala/code/api/util/ApiRole.scala | 15 ++ .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../util/newstyle/CounterpartyAttribute.scala | 94 ++++++++ .../scala/code/api/v6_0_0/APIMethods600.scala | 200 +++++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 37 ++++ .../CounterpartyAttribute.scala | 45 ++++ .../MappedCounterpartyAttributeProvider.scala | 103 +++++++++ .../v6_0_0/CounterpartyAttributeTest.scala | 186 ++++++++++++++++ .../commons/model/CommonModelTrait.scala | 9 + .../commons/model/enums/Enumerations.scala | 7 + 12 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/newstyle/CounterpartyAttribute.scala create mode 100644 obp-api/src/main/scala/code/counterpartyattribute/CounterpartyAttribute.scala create mode 100644 obp-api/src/main/scala/code/counterpartyattribute/MappedCounterpartyAttributeProvider.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index ae27a4fdb6..480979aef4 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -121,6 +121,7 @@ import code.products.MappedProduct import code.ratelimiting.RateLimiting import code.regulatedentities.MappedRegulatedEntity import code.regulatedentities.attribute.RegulatedEntityAttribute +import code.counterpartyattribute.{CounterpartyAttribute => CounterpartyAttributeMapper} import code.scheduler._ import code.scope.{MappedScope, MappedUserScope} import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketPayment} @@ -1225,6 +1226,7 @@ object ToSchemify { CustomerAccountLink, TransactionIdMapping, RegulatedEntityAttribute, + CounterpartyAttributeMapper, BankAccountBalance, Group, AccountAccessRequest 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 294dd08cb4..44918b7ffb 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 @@ -6080,7 +6080,27 @@ object SwaggerDefinitionsJSON { lazy val regulatedEntityAttributesJsonV510 = RegulatedEntityAttributesJsonV510( List(regulatedEntityAttributeResponseJsonV510) ) - + + lazy val counterpartyAttributeRequestJsonV600 = CounterpartyAttributeRequestJsonV600( + name = "TAX_NUMBER", + attribute_type = "STRING", + value = "123456789", + is_active = Some(true) + ) + + lazy val counterpartyAttributeResponseJsonV600 = CounterpartyAttributeResponseJsonV600( + counterparty_id = counterpartyIdExample.value, + counterparty_attribute_id = "613c83ea-80f9-4560-8404-b9cd4ec42a7f", + name = "TAX_NUMBER", + attribute_type = "STRING", + value = "123456789", + is_active = Some(true) + ) + + lazy val counterpartyAttributesJsonV600 = CounterpartyAttributesJsonV600( + List(counterpartyAttributeResponseJsonV600) + ) + lazy val bankAccountBalanceRequestJsonV510 = BankAccountBalanceRequestJsonV510( balance_type = balanceTypeExample.value, balance_amount = balanceAmountExample.value 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 753f744d9b..a12bc5254a 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -731,6 +731,21 @@ object ApiRole extends MdcLoggable{ case class CanDeleteRegulatedEntityAttribute(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteRegulatedEntityAttribute = CanDeleteRegulatedEntityAttribute() + case class CanGetCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetCounterpartyAttribute = CanGetCounterpartyAttribute() + + case class CanGetCounterpartyAttributes(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetCounterpartyAttributes = CanGetCounterpartyAttributes() + + case class CanCreateCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateCounterpartyAttribute = CanCreateCounterpartyAttribute() + + case class CanUpdateCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateCounterpartyAttribute = CanUpdateCounterpartyAttribute() + + case class CanDeleteCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteCounterpartyAttribute = CanDeleteCounterpartyAttribute() + case class CanGetMethodRoutings(requiresBankId: Boolean = false) extends ApiRole lazy val canGetMethodRoutings = CanGetMethodRoutings() 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 160cbeaf30..f014ad9dbf 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -49,6 +49,7 @@ object ApiTag { val apiTagScope = ResourceDocTag("Scope") val apiTagOwnerRequired = ResourceDocTag("OwnerViewRequired") val apiTagCounterparty = ResourceDocTag("Counterparty") + val apiTagCounterpartyAttribute = ResourceDocTag("Counterparty-Attribute") val apiTagKyc = ResourceDocTag("KYC") val apiTagCustomer = ResourceDocTag("Customer") val apiTagRetailCustomer = ResourceDocTag("Retail-Customer") diff --git a/obp-api/src/main/scala/code/api/util/newstyle/CounterpartyAttribute.scala b/obp-api/src/main/scala/code/api/util/newstyle/CounterpartyAttribute.scala new file mode 100644 index 0000000000..95c9b21b3a --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/newstyle/CounterpartyAttribute.scala @@ -0,0 +1,94 @@ +package code.api.util.newstyle + +import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} +import code.api.util.ErrorMessages.InvalidConnectorResponse +import code.api.util.CallContext +import code.counterpartyattribute.CounterpartyAttributeX +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{CounterpartyAttributeTrait, CounterpartyId} +import com.openbankproject.commons.model.enums.CounterpartyAttributeType +import scala.concurrent.Future +import com.github.dwickern.macros.NameOf.nameOf + +object CounterpartyAttributeNewStyle { + + def createOrUpdateCounterpartyAttribute( + counterpartyId: CounterpartyId, + counterpartyAttributeId: Option[String], + name: String, + attributeType: CounterpartyAttributeType.Value, + value: String, + isActive: Option[Boolean], + callContext: Option[CallContext] + ): OBPReturnType[CounterpartyAttributeTrait] = { + CounterpartyAttributeX.counterpartyAttributeProvider.vend.createOrUpdateCounterpartyAttribute( + counterpartyId: CounterpartyId, + counterpartyAttributeId: Option[String], + name: String, + attributeType: CounterpartyAttributeType.Value, + value: String, + isActive: Option[Boolean] + ) + }.map { + result => + ( + unboxFullOrFail( + result, + callContext, + s"$InvalidConnectorResponse ${nameOf(createOrUpdateCounterpartyAttribute _)}", + 400), + callContext + ) + } + + def getCounterpartyAttributeById( + attributeId: String, + callContext: Option[CallContext] + ): OBPReturnType[CounterpartyAttributeTrait] = { + CounterpartyAttributeX.counterpartyAttributeProvider.vend.getCounterpartyAttributeById(attributeId).map { + result => + ( + unboxFullOrFail( + result, + callContext, + s"$InvalidConnectorResponse ${nameOf(getCounterpartyAttributeById _)}", + 404), + callContext + ) + } + } + + def getCounterpartyAttributes( + counterpartyId: CounterpartyId, + callContext: Option[CallContext] + ): OBPReturnType[List[CounterpartyAttributeTrait]] = { + CounterpartyAttributeX.counterpartyAttributeProvider.vend.getCounterpartyAttributes(counterpartyId).map { + result => + ( + unboxFullOrFail( + result, + callContext, + s"$InvalidConnectorResponse ${nameOf(getCounterpartyAttributes _)}", + 404), + callContext + ) + } + } + + def deleteCounterpartyAttribute( + attributeId: String, + callContext: Option[CallContext] + ): OBPReturnType[Boolean] = { + CounterpartyAttributeX.counterpartyAttributeProvider.vend.deleteCounterpartyAttribute(attributeId).map { + result => + ( + unboxFullOrFail( + result, + callContext, + s"$InvalidConnectorResponse ${nameOf(deleteCounterpartyAttribute _)}", + 400), + callContext + ) + } + } +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 11d98009f0..8959fed07d 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -35,7 +35,7 @@ import code.metadata.tags.Tags import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.mandate.{MappedMandateProvider} -import code.api.v6_0_0.JSONFactory600.{createMandateJsonV600, createMandatesJsonV600, createMandateProvisionJsonV600, createMandateProvisionsJsonV600, createSignatoryPanelJsonV600, createSignatoryPanelsJsonV600} +import code.api.v6_0_0.JSONFactory600.{createMandateJsonV600, createMandatesJsonV600, createMandateProvisionJsonV600, createMandateProvisionsJsonV600, createSignatoryPanelJsonV600, createSignatoryPanelsJsonV600, createCounterpartyAttributeJson, createCounterpartyAttributesJson} import code.metrics.{APIMetrics, ConnectorCountsRedis, ConnectorTraceProvider} import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.bankconnectors.storedprocedure.StoredProcedureUtils @@ -57,8 +57,10 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.dto.GetProductsParam import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.CounterpartyAttributeType import com.openbankproject.commons.model.enums.DynamicEntityOperation._ import com.openbankproject.commons.model.enums.UserAttributeType +import code.api.util.newstyle.CounterpartyAttributeNewStyle import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} import net.liftweb.common.{Box, Empty, Failure, Full} import net.liftweb.util.Helpers.tryo @@ -12241,6 +12243,202 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + createCounterpartyAttribute, + implementedInApiVersion, + nameOf(createCounterpartyAttribute), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes", + "Create Counterparty Attribute", + s""" + | Create a new Counterparty Attribute for a given COUNTERPARTY_ID. + | + | The type field must be one of "STRING", "INTEGER", "DOUBLE" or "DATE_WITH_DAY". + | Authentication is Required + | + """.stripMargin, + counterpartyAttributeRequestJsonV600, + counterpartyAttributeResponseJsonV600, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyAttribute, apiTagApi), + Some(List(canCreateCounterpartyAttribute)) + ) + + lazy val createCounterpartyAttribute: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "counterparties" :: counterpartyId :: "attributes" :: Nil JsonPost json -> _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CounterpartyAttributeRequestJsonV600 ", 400, cc.callContext) { + json.extract[CounterpartyAttributeRequestJsonV600] + } + failMsg = s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${CounterpartyAttributeType.DOUBLE}(12.1234), ${CounterpartyAttributeType.STRING}(TAX_NUMBER), ${CounterpartyAttributeType.INTEGER}(123) and ${CounterpartyAttributeType.DATE_WITH_DAY}(2012-04-23)" + + counterpartyAttributeType <- NewStyle.function.tryons(failMsg, 400, cc.callContext) { + CounterpartyAttributeType.withName(postedData.attribute_type) + } + + (attribute, callContext) <- CounterpartyAttributeNewStyle.createOrUpdateCounterpartyAttribute( + counterpartyId = CounterpartyId(counterpartyId), + counterpartyAttributeId = None, + name = postedData.name, + attributeType = counterpartyAttributeType, + value = postedData.value, + isActive = postedData.is_active, + callContext = cc.callContext + ) + } yield { + (JSONFactory600.createCounterpartyAttributeJson(attribute), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + deleteCounterpartyAttribute, + implementedInApiVersion, + nameOf(deleteCounterpartyAttribute), + "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "Delete Counterparty Attribute", + s""" + | Delete a Counterparty Attribute specified by COUNTERPARTY_ATTRIBUTE_ID. + | + | Authentication is Required + | + """.stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagCounterpartyAttribute, apiTagApi), + Some(List(canDeleteCounterpartyAttribute)) + ) + + lazy val deleteCounterpartyAttribute: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "counterparties" :: counterpartyId :: "attributes" :: attributeId :: Nil JsonDelete _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (deleted, callContext) <- CounterpartyAttributeNewStyle.deleteCounterpartyAttribute(attributeId, cc.callContext) + } yield { + (Full(deleted), HttpCode.`204`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCounterpartyAttributeById, + implementedInApiVersion, + nameOf(getCounterpartyAttributeById), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "Get Counterparty Attribute By ID", + s""" + | Get a specific Counterparty Attribute by its COUNTERPARTY_ATTRIBUTE_ID. + | + | Authentication is Required + | + """.stripMargin, + EmptyBody, + counterpartyAttributeResponseJsonV600, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagCounterpartyAttribute, apiTagApi), + Some(List(canGetCounterpartyAttribute)) + ) + + lazy val getCounterpartyAttributeById: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "counterparties" :: counterpartyId :: "attributes" :: attributeId :: Nil JsonGet _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (attribute, callContext) <- CounterpartyAttributeNewStyle.getCounterpartyAttributeById(attributeId, cc.callContext) + } yield { + (JSONFactory600.createCounterpartyAttributeJson(attribute), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getAllCounterpartyAttributes, + implementedInApiVersion, + nameOf(getAllCounterpartyAttributes), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes", + "Get All Counterparty Attributes", + s""" + | Get all attributes for the specified Counterparty. + | + | Authentication is Required + | + """.stripMargin, + EmptyBody, + counterpartyAttributesJsonV600, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagCounterpartyAttribute, apiTagApi), + Some(List(canGetCounterpartyAttributes)) + ) + + lazy val getAllCounterpartyAttributes: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "counterparties" :: counterpartyId :: "attributes" :: Nil JsonGet _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (attributes, callContext) <- CounterpartyAttributeNewStyle.getCounterpartyAttributes(CounterpartyId(counterpartyId), cc.callContext) + } yield { + (JSONFactory600.createCounterpartyAttributesJson(attributes), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + updateCounterpartyAttribute, + implementedInApiVersion, + nameOf(updateCounterpartyAttribute), + "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "Update Counterparty Attribute", + s""" + | Update an existing Counterparty Attribute specified by COUNTERPARTY_ATTRIBUTE_ID. + | + | Authentication is Required + | + """.stripMargin, + counterpartyAttributeRequestJsonV600, + counterpartyAttributeResponseJsonV600, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagCounterpartyAttribute, apiTagApi), + Some(List(canUpdateCounterpartyAttribute)) + ) + + lazy val updateCounterpartyAttribute: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "counterparties" :: counterpartyId :: "attributes" :: attributeId :: Nil JsonPut json -> _ => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $CounterpartyAttributeRequestJsonV600 ", 400, cc.callContext) { + json.extract[CounterpartyAttributeRequestJsonV600] + } + failMsg = s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${CounterpartyAttributeType.DOUBLE}(12.1234), ${CounterpartyAttributeType.STRING}(TAX_NUMBER), ${CounterpartyAttributeType.INTEGER}(123) and ${CounterpartyAttributeType.DATE_WITH_DAY}(2012-04-23)" + + counterpartyAttributeType <- NewStyle.function.tryons(failMsg, 400, cc.callContext) { + CounterpartyAttributeType.withName(postedData.attribute_type) + } + (updatedAttribute, callContext) <- CounterpartyAttributeNewStyle.createOrUpdateCounterpartyAttribute( + counterpartyId = CounterpartyId(counterpartyId), + counterpartyAttributeId = Some(attributeId), + name = postedData.name, + attributeType = counterpartyAttributeType, + value = postedData.value, + isActive = postedData.is_active, + callContext = cc.callContext + ) + } yield { + (JSONFactory600.createCounterpartyAttributeJson(updatedAttribute), HttpCode.`200`(callContext)) + } + } + } + } } 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 5db9e86ab2..e392c53d60 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 @@ -65,6 +65,26 @@ case class FeaturesJsonV600( allow_account_deletion: Boolean ) +case class CounterpartyAttributeRequestJsonV600( + name: String, + attribute_type: String, + value: String, + is_active: Option[Boolean] +) + +case class CounterpartyAttributeResponseJsonV600( + counterparty_id: String, + counterparty_attribute_id: String, + name: String, + attribute_type: String, + value: String, + is_active: Option[Boolean] +) + +case class CounterpartyAttributesJsonV600( + attributes: List[CounterpartyAttributeResponseJsonV600] +) + case class CardanoPaymentJsonV600( address: String, amount: CardanoAmountJsonV600, @@ -2692,4 +2712,21 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + def createCounterpartyAttributeJson(attribute: CounterpartyAttributeTrait): CounterpartyAttributeResponseJsonV600 = { + CounterpartyAttributeResponseJsonV600( + counterparty_id = attribute.counterpartyId.value, + counterparty_attribute_id = attribute.counterpartyAttributeId, + name = attribute.name, + attribute_type = attribute.attributeType.toString, + value = attribute.value, + is_active = attribute.isActive + ) + } + + def createCounterpartyAttributesJson(attributes: List[CounterpartyAttributeTrait]): CounterpartyAttributesJsonV600 = { + CounterpartyAttributesJsonV600( + attributes.map(createCounterpartyAttributeJson) + ) + } + } diff --git a/obp-api/src/main/scala/code/counterpartyattribute/CounterpartyAttribute.scala b/obp-api/src/main/scala/code/counterpartyattribute/CounterpartyAttribute.scala new file mode 100644 index 0000000000..c9bd3b45dd --- /dev/null +++ b/obp-api/src/main/scala/code/counterpartyattribute/CounterpartyAttribute.scala @@ -0,0 +1,45 @@ +package code.counterpartyattribute + +import com.openbankproject.commons.model.CounterpartyId +import com.openbankproject.commons.model.enums.CounterpartyAttributeType +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +import scala.concurrent.Future + +object CounterpartyAttributeX extends SimpleInjector { + + val counterpartyAttributeProvider = new Inject(buildOne _) {} + + def buildOne: CounterpartyAttributeProviderTrait = CounterpartyAttributeProvider + + // Helper to get the count out of an option + def countOfCounterpartyAttribute(listOpt: Option[List[CounterpartyAttribute]]): Int = { + val count = listOpt match { + case Some(list) => list.size + case None => 0 + } + count + } + + +} + +trait CounterpartyAttributeProviderTrait { + + def getCounterpartyAttributes(counterpartyId: CounterpartyId): Future[Box[List[CounterpartyAttribute]]] + + def getCounterpartyAttributeById(counterpartyAttributeId: String): Future[Box[CounterpartyAttribute]] + + def createOrUpdateCounterpartyAttribute( + counterpartyId: CounterpartyId, + counterpartyAttributeId: Option[String], + name: String, + attributeType: CounterpartyAttributeType.Value, + value: String, + isActive: Option[Boolean]): Future[Box[CounterpartyAttribute]] + + def deleteCounterpartyAttribute(counterpartyAttributeId: String): Future[Box[Boolean]] + + def deleteCounterpartyAttributesByCounterpartyId(counterpartyId: CounterpartyId): Future[Box[Boolean]] +} diff --git a/obp-api/src/main/scala/code/counterpartyattribute/MappedCounterpartyAttributeProvider.scala b/obp-api/src/main/scala/code/counterpartyattribute/MappedCounterpartyAttributeProvider.scala new file mode 100644 index 0000000000..ce67a24de0 --- /dev/null +++ b/obp-api/src/main/scala/code/counterpartyattribute/MappedCounterpartyAttributeProvider.scala @@ -0,0 +1,103 @@ +package code.counterpartyattribute + +import code.util.{MappedUUID, UUIDString} +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.enums.CounterpartyAttributeType +import com.openbankproject.commons.model.{CounterpartyAttributeTrait, CounterpartyId} +import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.mapper.{MappedBoolean, _} +import net.liftweb.util.Helpers.tryo + +import scala.concurrent.Future + + +object CounterpartyAttributeProvider extends CounterpartyAttributeProviderTrait { + + override def getCounterpartyAttributes(counterpartyId: CounterpartyId): Future[Box[List[CounterpartyAttribute]]] = + Future { + Box !! CounterpartyAttribute.findAll( + By(CounterpartyAttribute.CounterpartyId_, counterpartyId.value) + ) + } + + override def getCounterpartyAttributeById(counterpartyAttributeId: String): Future[Box[CounterpartyAttribute]] = Future { + CounterpartyAttribute.find(By(CounterpartyAttribute.CounterpartyAttributeId, counterpartyAttributeId)) + } + + override def createOrUpdateCounterpartyAttribute( + counterpartyId: CounterpartyId, + counterpartyAttributeId: Option[String], + name: String, + attributeType: CounterpartyAttributeType.Value, + value: String, + isActive: Option[Boolean] + ): Future[Box[CounterpartyAttribute]] = { + counterpartyAttributeId match { + case Some(id) => Future { + CounterpartyAttribute.find(By(CounterpartyAttribute.CounterpartyAttributeId, id)) match { + case Full(attribute) => tryo { + attribute + .CounterpartyId_(counterpartyId.value) + .Name(name) + .Type(attributeType.toString) + .`Value`(value) + .IsActive(isActive.getOrElse(true)) + .saveMe() + } + case _ => Empty + } + } + case None => Future { + Full { + CounterpartyAttribute.create + .CounterpartyId_(counterpartyId.value) + .Name(name) + .Type(attributeType.toString()) + .`Value`(value) + .IsActive(isActive.getOrElse(true)) + .saveMe() + } + } + } + } + + override def deleteCounterpartyAttribute(counterpartyAttributeId: String): Future[Box[Boolean]] = Future { + tryo( + CounterpartyAttribute.bulkDelete_!!(By(CounterpartyAttribute.CounterpartyAttributeId, counterpartyAttributeId)) + ) + } + + override def deleteCounterpartyAttributesByCounterpartyId(counterpartyId: CounterpartyId): Future[Box[Boolean]] = Future { + tryo( + CounterpartyAttribute.bulkDelete_!!(By(CounterpartyAttribute.CounterpartyId_, counterpartyId.value)) + ) + } +} + +class CounterpartyAttribute extends CounterpartyAttributeTrait with LongKeyedMapper[CounterpartyAttribute] with IdPK { + + override def getSingleton = CounterpartyAttribute + + object CounterpartyId_ extends UUIDString(this) { + override def dbColumnName = "CounterpartyId" + } + object CounterpartyAttributeId extends MappedUUID(this) + object Name extends MappedString(this, 50) + object Type extends MappedString(this, 50) + object `Value` extends MappedString(this, 255) + object IsActive extends MappedBoolean(this) { + override def defaultValue = true + } + + override def counterpartyId: CounterpartyId = CounterpartyId(CounterpartyId_.get) + override def counterpartyAttributeId: String = CounterpartyAttributeId.get + override def name: String = Name.get + override def attributeType: CounterpartyAttributeType.Value = CounterpartyAttributeType.withName(Type.get) + override def value: String = `Value`.get + override def isActive: Option[Boolean] = if (IsActive.jdbcFriendly(IsActive.calcFieldName) == null) { None } else Some(IsActive.get) + +} + +object CounterpartyAttribute extends CounterpartyAttribute with LongKeyedMetaMapper[CounterpartyAttribute] { + override def dbIndexes: List[BaseIndex[CounterpartyAttribute]] = Index(CounterpartyId_) :: super.dbIndexes +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala new file mode 100644 index 0000000000..a1ca21f7c6 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala @@ -0,0 +1,186 @@ +package code.api.v6_0_0 + +import java.util.UUID +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.ErrorMessages +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.setup.DefaultUsers +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + +class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object Create extends Tag(nameOf(Implementations6_0_0.createCounterpartyAttribute)) + object Update extends Tag(nameOf(Implementations6_0_0.updateCounterpartyAttribute)) + object Delete extends Tag(nameOf(Implementations6_0_0.deleteCounterpartyAttribute)) + object GetAll extends Tag(nameOf(Implementations6_0_0.getAllCounterpartyAttributes)) + object GetOne extends Tag(nameOf(Implementations6_0_0.getCounterpartyAttributeById)) + + val bankId = testBankId1.value + val accountId = testAccountId1.value + lazy val counterpartyId = createMockCounterparty() + lazy val attributeId = createMockAttribute(counterpartyId) + + def createMockCounterparty(): String = { + val counterparty = createCounterparty(bankId, accountId, accountId, true, UUID.randomUUID.toString) + counterparty.counterpartyId + } + + def createMockAttribute(counterpartyId: String): String = { + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 + val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + response.body.extract[CounterpartyAttributeResponseJsonV600].counterparty_attribute_id + } + + feature("Create Counterparty Attribute") { + + scenario("401 Unauthorized", Create, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST + val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + } + + scenario("403 Forbidden (no role)", Create, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 + val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ErrorMessages.UserHasMissingRoles + CanCreateCounterpartyAttribute) + } + + scenario("201 Success + Field Echo", Create, VersionOfApi) { + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 + val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(201) + val created = response.body.extract[CounterpartyAttributeResponseJsonV600] + created.name should equal(counterpartyAttributeRequestJsonV600.name) + created.attribute_type should equal(counterpartyAttributeRequestJsonV600.attribute_type) + created.value should equal(counterpartyAttributeRequestJsonV600.value) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + + scenario("400 Invalid Type", Create, VersionOfApi) { + val badJson = counterpartyAttributeRequestJsonV600.copy(attribute_type = "UNSUPPORTED") + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 + val response = makePostRequest(request, write(badJson)) + response.code should equal(400) + response.body.extract[ErrorMessage].message should include("field can only accept") + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + } + + feature("Update Counterparty Attribute") { + + scenario("401 Unauthorized", Update, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).PUT + val response = makePutRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(401) + } + + scenario("403 Forbidden", Update, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).PUT <@ user1 + val response = makePutRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(403) + } + + scenario("200 Success", Update, VersionOfApi) { + lazy val counterpartyId = createMockCounterparty() + lazy val attributeId = createMockAttribute(counterpartyId) + + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).PUT <@ user1 + val response = makePutRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(200) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + } + + feature("Delete Counterparty Attribute") { + lazy val counterpartyId = createMockCounterparty() + lazy val attributeId = createMockAttribute(counterpartyId) + scenario("401 Unauthorized", Delete, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).DELETE + val response = makeDeleteRequest(request) + response.code should equal(401) + } + + scenario("403 Forbidden", Delete, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).DELETE <@ user1 + val response = makeDeleteRequest(request) + response.code should equal(403) + } + + scenario("204 Success", Delete, VersionOfApi) { + lazy val counterpartyId = createMockCounterparty() + lazy val attributeId = createMockAttribute(counterpartyId) + + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).DELETE <@ user1 + val response = makeDeleteRequest(request) + response.code should equal(204) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + } + + feature("Get All Counterparty Attributes") { + lazy val counterpartyId = createMockCounterparty() + lazy val attributeId = createMockAttribute(counterpartyId) + scenario("401 Unauthorized", GetAll, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").GET + val response = makeGetRequest(request) + response.code should equal(401) + } + + scenario("403 Forbidden", GetAll, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").GET <@ user1 + val response = makeGetRequest(request) + response.code should equal(403) + } + + scenario("200 Success", GetAll, VersionOfApi) { + lazy val counterpartyId = createMockCounterparty() + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCounterpartyAttributes.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").GET <@ user1 + val response = makeGetRequest(request) + response.code should equal(200) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + } + + feature("Get Counterparty Attribute by ID") { + lazy val counterpartyId = createMockCounterparty() + + scenario("401 Unauthorized", GetOne, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).GET + val response = makeGetRequest(request) + response.code should equal(401) + } + + scenario("403 Forbidden", GetOne, VersionOfApi) { + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).GET <@ user1 + val response = makeGetRequest(request) + response.code should equal(403) + } + + scenario("200 Success", GetOne, VersionOfApi) { + lazy val counterpartyId = createMockCounterparty() + lazy val attributeId = createMockAttribute(counterpartyId) + val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).GET <@ user1 + val response = makeGetRequest(request) + response.code should equal(200) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + } +} diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala index 1b7a69b882..73e58c53b8 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala @@ -550,6 +550,15 @@ trait RegulatedEntityAttributeTrait { def isActive: Option[Boolean] } +trait CounterpartyAttributeTrait { + def counterpartyId: CounterpartyId + def counterpartyAttributeId: String + def attributeType: CounterpartyAttributeType.Value + def name: String + def value: String + def isActive: Option[Boolean] +} + trait BankAttributeTrait { def bankId: BankId def bankAttributeId: String diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index cc587c6c96..ee935e0908 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -29,6 +29,13 @@ object RegulatedEntityAttributeType extends OBPEnumeration[RegulatedEntityAttrib object DOUBLE extends Value object DATE_WITH_DAY extends Value } +sealed trait CounterpartyAttributeType extends EnumValue +object CounterpartyAttributeType extends OBPEnumeration[CounterpartyAttributeType]{ + object STRING extends Value + object INTEGER extends Value + object DOUBLE extends Value + object DATE_WITH_DAY extends Value +} sealed trait BankAttributeType extends EnumValue object BankAttributeType extends OBPEnumeration[BankAttributeType]{ object STRING extends Value From fad73e4e30a7734c3fed284ec4d11a6a9bcb9161 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 13 Mar 2026 23:48:50 +0100 Subject: [PATCH 05/11] Set authMode = UserOrApplication for OIDC / Consumer bootstrap related endpoints. --- .../src/main/scala/code/api/v5_1_0/APIMethods510.scala | 6 ++++-- .../src/main/scala/code/api/v6_0_0/APIMethods600.scala | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 52a3977642..8939431165 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -3637,7 +3637,8 @@ trait APIMethods510 { UnknownError ), List(apiTagConsumer), - Some(List(canCreateConsumer)) + Some(List(canCreateConsumer)), + authMode = UserOrApplication ) lazy val createConsumer: OBPEndpoint = { @@ -4022,7 +4023,8 @@ trait APIMethods510 { UnknownError ), List(apiTagConsumer), - Some(List(canGetConsumers)) + Some(List(canGetConsumers)), + authMode = UserOrApplication ) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 8959fed07d..97eb57605c 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -829,7 +829,8 @@ trait APIMethods600 { UnknownError ), List(apiTagConsumer), - Some(List(canGetConsumers)) + Some(List(canGetConsumers)), + authMode = UserOrApplication ) lazy val getConsumer: OBPEndpoint = { @@ -8910,7 +8911,8 @@ trait APIMethods600 { UnknownError ), List(apiTagOIDC, apiTagConsumer, apiTagOAuth), - Some(List(canVerifyOidcClient)) + Some(List(canVerifyOidcClient)), + authMode = UserOrApplication ) lazy val verifyOidcClient: OBPEndpoint = { @@ -8974,7 +8976,8 @@ trait APIMethods600 { UnknownError ), List(apiTagOIDC, apiTagConsumer, apiTagOAuth), - Some(List(canGetOidcClient)) + Some(List(canGetOidcClient)), + authMode = UserOrApplication ) lazy val getOidcClient: OBPEndpoint = { From 01a4a9443df0e9264e7b3520f4f656fb3f42f57f Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 14 Mar 2026 00:06:54 +0100 Subject: [PATCH 06/11] createBootstrapOidcOperatorConsumer --- .../resources/props/sample.props.template | 11 +++ .../main/scala/bootstrap/liftweb/Boot.scala | 73 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 4c2583218d..636ed4225a 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1655,6 +1655,17 @@ regulated_entities = [] # oidc_operator_initial_password=... # oidc_operator_email=... +# Bootstrap OIDC Operator Consumer +# Given the following key and secret, OBP will create a consumer if it does not already exist. +# This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient +# This allows OBP-OIDC to authenticate as an application and manage consumers via the API. +# Note: If you use this, you may not need the Bootstrap OIDC Operator User above, +# depending on how OBP-OIDC implements its authentication. +# If you want to use this feature, please set up both values properly at the same time. +# Both values must be between 10 and 250 characters. +# oidc_operator_consumer_key=... +# oidc_operator_consumer_secret=... + ## Ethereum Connector Configuration ## ================================ diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 480979aef4..77a3f9f4a8 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -123,7 +123,7 @@ import code.regulatedentities.MappedRegulatedEntity import code.regulatedentities.attribute.RegulatedEntityAttribute import code.counterpartyattribute.{CounterpartyAttribute => CounterpartyAttributeMapper} import code.scheduler._ -import code.scope.{MappedScope, MappedUserScope} +import code.scope.{MappedScope, MappedUserScope, Scope} import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketPayment} import code.socialmedia.MappedSocialMedia import code.standingorders.StandingOrder @@ -338,6 +338,8 @@ class Boot extends MdcLoggable { createBootstrapOidcOperatorUser() + createBootstrapOidcOperatorConsumer() + //launch the scheduler to clean the database from the expired tokens and nonces, 1 hour DataBaseCleanerScheduler.start(intervalInSeconds = 60*60) @@ -1096,6 +1098,75 @@ class Boot extends MdcLoggable { } } + /** + * Bootstrap OIDC Operator Consumer + * Given the following key and secret, OBP will create a consumer *if it does not exist already*. + * This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient + * This allows OBP-OIDC to authenticate as an application (without a user) and manage consumers via the API. + */ + private def createBootstrapOidcOperatorConsumer() = { + + val oidcOperatorConsumerKey = APIUtil.getPropsValue("oidc_operator_consumer_key", "") + val oidcOperatorConsumerSecret = APIUtil.getPropsValue("oidc_operator_consumer_secret", "") + + val isPropsNotSetProperly = oidcOperatorConsumerKey == "" || oidcOperatorConsumerSecret == "" + + if (isPropsNotSetProperly) { + logger.info(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_key and/or oidc_operator_consumer_secret props are not set, skipping") + } else if (oidcOperatorConsumerKey.length < 10) { + logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_key is too short (${oidcOperatorConsumerKey.length} chars, minimum 10), skipping") + } else if (oidcOperatorConsumerKey.length > 250) { + logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_key is too long (${oidcOperatorConsumerKey.length} chars, maximum 250), skipping") + } else if (oidcOperatorConsumerSecret.length < 10) { + logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_secret is too short (${oidcOperatorConsumerSecret.length} chars, minimum 10), skipping") + } else if (oidcOperatorConsumerSecret.length > 250) { + logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_secret is too long (${oidcOperatorConsumerSecret.length} chars, maximum 250), skipping") + } else { + val existingConsumer = Consumers.consumers.vend.getConsumerByConsumerKey(oidcOperatorConsumerKey) + + if (existingConsumer.isDefined) { + logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer with key ${oidcOperatorConsumerKey} already exists, skipping creation") + } else { + val consumerBox = Consumers.consumers.vend.createConsumer( + Some(oidcOperatorConsumerKey), + Some(oidcOperatorConsumerSecret), + Some(true), // isActive + Some("OIDC Operator Consumer"), // name + None, // appType + Some("Bootstrap consumer for OBP-OIDC to manage consumers via the API"), // description + Some(""), // developerEmail + None, // redirectURL + None, // createdByUserId + None, // clientCertificate + None, // company + None // logoURL + ) + + consumerBox match { + case Full(consumer) => + logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer created successfully") + + val oidcOperatorConsumerScopes = List( + CanGetConsumers, + CanCreateConsumer, + CanVerifyOidcClient, + CanGetOidcClient + ) + + oidcOperatorConsumerScopes.foreach { role => + val resultBox = Scope.scope.vend.addScope("", consumer.id.get.toString, role.toString) + if (resultBox.isEmpty) { + logger.error(s"createBootstrapOidcOperatorConsumer says: Error granting scope ${role}: ${resultBox}") + } + } + + case _ => + logger.error(s"createBootstrapOidcOperatorConsumer says: Error creating consumer") + } + } + } + } + LiftRules.statelessDispatch.append(aliveCheck) } From ff674ecf883df6b09164d216795a64e509836999 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 14 Mar 2026 01:23:44 +0100 Subject: [PATCH 07/11] Glossary Item: Authentication: OAuth2 / OIDC Client Credentials and Bootstrap Consumer --- .../main/scala/bootstrap/liftweb/Boot.scala | 67 ++++++------ .../main/scala/code/api/util/Glossary.scala | 103 ++++++++++++++++++ 2 files changed, 135 insertions(+), 35 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 77a3f9f4a8..758c8c666f 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -73,6 +73,7 @@ import code.cards.{MappedPhysicalCard, PinReset} import code.connectormethod.ConnectorMethod import code.consent.{ConsentRequest, MappedConsent} import code.consumer.Consumers +import code.model.Consumer import code.context.{MappedConsentAuthContext, MappedUserAuthContext, MappedUserAuthContextUpdate} import code.counterpartylimit.CounterpartyLimit import code.crm.MappedCrmEvent @@ -1127,43 +1128,39 @@ class Boot extends MdcLoggable { if (existingConsumer.isDefined) { logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer with key ${oidcOperatorConsumerKey} already exists, skipping creation") } else { - val consumerBox = Consumers.consumers.vend.createConsumer( - Some(oidcOperatorConsumerKey), - Some(oidcOperatorConsumerSecret), - Some(true), // isActive - Some("OIDC Operator Consumer"), // name - None, // appType - Some("Bootstrap consumer for OBP-OIDC to manage consumers via the API"), // description - Some(""), // developerEmail - None, // redirectURL - None, // createdByUserId - None, // clientCertificate - None, // company - None // logoURL - ) - - consumerBox match { - case Full(consumer) => - logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer created successfully") - - val oidcOperatorConsumerScopes = List( - CanGetConsumers, - CanCreateConsumer, - CanVerifyOidcClient, - CanGetOidcClient - ) - - oidcOperatorConsumerScopes.foreach { role => - val resultBox = Scope.scope.vend.addScope("", consumer.id.get.toString, role.toString) - if (resultBox.isEmpty) { - logger.error(s"createBootstrapOidcOperatorConsumer says: Error granting scope ${role}: ${resultBox}") - } - } + saveOidcOperatorConsumer(oidcOperatorConsumerKey, oidcOperatorConsumerSecret) + } + } + } - case _ => - logger.error(s"createBootstrapOidcOperatorConsumer says: Error creating consumer") + // Separate method to create and save the OIDC operator consumer. + // Uses Consumer.create directly (not Consumers.consumers.vend.createConsumer) + // to avoid S.? calls during Boot (Lift's S scope is not initialized at boot time). + private def saveOidcOperatorConsumer(consumerKey: String, consumerSecret: String): Unit = { + // Create consumer directly, skipping validate (which calls S.? and fails during Boot) + val c = Consumer.create + .key(consumerKey) + .secret(consumerSecret) + .name("OIDC Operator Consumer") + c.isActive(true) // MappedBoolean.apply returns Mapper, must be separate statement + c.description("Bootstrap consumer for OBP-OIDC to manage consumers via the API") // MappedText.apply returns Mapper, must be separate statement + + val consumerBox = tryo(c.saveMe()) + + consumerBox match { + case Full(consumer) => + logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer created successfully with consumer_id: ${consumer.consumerId.get}") + val scopes = List(CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient) + scopes.foreach { role => + val resultBox = Scope.scope.vend.addScope("", consumer.id.get.toString, role.toString) + if (resultBox.isEmpty) { + logger.error(s"createBootstrapOidcOperatorConsumer says: Error granting scope ${role}: ${resultBox}") + } } - } + case net.liftweb.common.Failure(msg, exception, _) => + logger.error(s"createBootstrapOidcOperatorConsumer says: Error creating consumer: $msg ${exception.map(_.getMessage).openOr("")}") + case _ => + logger.error("createBootstrapOidcOperatorConsumer says: Error creating consumer (unknown error)") } } 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 fc4281d9a0..5e8089a8a5 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -1464,6 +1464,109 @@ object Glossary extends MdcLoggable { """) + glossaryItems += GlossaryItem( + title = "Authentication: OAuth2 / OIDC Client Credentials", + description = + s""" + |OAuth2 Client Credentials authentication allows an application (Consumer) to call OBP-API endpoints without a user login. + |This is used for endpoints with authMode set to `ApplicationOnly` or `UserOrApplication`. + | + |### Overview + | + |Instead of authenticating a user with Direct Login, the application authenticates itself using its `client_id` (consumer_key) and `client_secret` (consumer_secret) via the OAuth2 Client Credentials grant type. + |The application obtains a Bearer token from the OIDC provider's token endpoint and then uses that token to call OBP-API. + | + |### 1) Register your Application + | + |Register your App / Consumer at OBP-API to obtain a `consumer_key` and `consumer_secret`. + | + |The `consumer_key` corresponds to the `client_id` in the OIDC provider. + |The `consumer_secret` corresponds to the `client_secret` in the OIDC provider. + | + |Ensure the Consumer record is active and the OIDC provider (e.g. Keycloak, Hydra, or OBP-OIDC) has a matching client configured. + | + |### 2) Obtain a Bearer Token + | + |Request an access token from the OIDC provider's token endpoint using the `client_credentials` grant type: + | + |``` + |POST /realms/master/protocol/openid-connect/token HTTP/1.1 + |Host: your-oidc-provider.example.com + |Content-Type: application/x-www-form-urlencoded + | + |client_id=your-consumer-key&client_secret=your-consumer-secret&grant_type=client_credentials + |``` + | + |Or using cURL: + | + |``` + |curl -X POST "https://your-oidc-provider.example.com/realms/master/protocol/openid-connect/token" \\ + | -H "Content-Type: application/x-www-form-urlencoded" \\ + | -d "client_id=your-consumer-key" \\ + | -d "client_secret=your-consumer-secret" \\ + | -d "grant_type=client_credentials" + |``` + | + |You should receive a response like: + | + |``` + |{ + | "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + | "token_type": "Bearer", + | "expires_in": 3600 + |} + |``` + | + |### 3) Call OBP-API Endpoints + | + |Use the access token in the `Authorization` header of your API requests: + | + |``` + |GET $getObpApiRoot/v6.0.0/some-endpoint HTTP/1.1 + |Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... + |Content-Type: application/json + |``` + | + |Or using cURL: + | + |``` + |curl -X GET "$getObpApiRoot/v6.0.0/some-endpoint" \\ + | -H "Authorization: Bearer your-access-token" \\ + | -H "Content-Type: application/json" + |``` + | + |### How It Works + | + |When OBP-API receives a request with an `Authorization: Bearer` header: + | + |1. It extracts the JWT token from the header. + |2. It identifies the OIDC provider from the token's `iss` (issuer) claim. + |3. It validates the token signature against the provider's JWKS (JSON Web Key Set). + |4. It extracts the `azp` (authorized party) or `sub` (subject) claim to identify the Consumer. + |5. For `ApplicationOnly` endpoints, no user is required — the Consumer identity is sufficient. + |6. For `UserOrApplication` endpoints, either the Consumer's scopes or a user's entitlements can satisfy the authorization check. + | + |### Consumer Scopes + | + |For the application to be authorized to call a protected endpoint, the Consumer must have the required Scope(s) assigned. + |Scopes link a Consumer to an API Role at a specific bank. They are the application-level equivalent of user Entitlements. + | + |### Endpoint Auth Modes + | + |Each endpoint has an `authMode` that determines what authentication is required: + | + |* `UserOnly` — Requires user authentication (e.g. Direct Login or OAuth2 with user login). This is the default. + |* `ApplicationOnly` — Requires only Consumer/application authentication. No user login needed. + |* `UserOrApplication` — Either a logged-in user with the right Entitlement, or an application with the right Scope. + |* `UserAndApplication` — Both user Entitlement and Consumer Scope are required. + | + |### Note + | + |The `client_id` used at the OIDC provider must match the `consumer_key` (or `azp` claim) of an active Consumer record in OBP-API. + | + """) + + glossaryItems += GlossaryItem( title = "Echo Request Headers", description = From 2b13f6819808bee68bf004e4f445c5de5732e91a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 14 Mar 2026 01:34:49 +0100 Subject: [PATCH 08/11] API.Endpoint Auth Modes in Glossary.scala --- .../main/scala/code/api/util/Glossary.scala | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 5e8089a8a5..8d4284e8ec 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -790,6 +790,50 @@ object Glossary extends MdcLoggable { + glossaryItems += GlossaryItem( + title = "API.Endpoint Auth Modes", + description = + s""" +| +|Each API endpoint has an **authMode** that determines how Roles are checked when both a User and a Consumer (Application) are present in the request. +| +|The four auth modes are: +| +|* **UserOnly** (default): Only the User's Entitlements are checked. Consumer Scopes are ignored. +| +|* **ApplicationOnly**: Only the Consumer's Scopes are checked. No User is required. +| +|* **UserOrApplication**: Access is granted if the Consumer has the required Scope **OR** the User has the required Entitlement. This effectively gives the union of both. +| +|* **UserAndApplication**: Access is granted only if the Consumer has the required Scope **AND** the User has the required Entitlement. Both are required. +| +|For example, if a User logs in via DirectLogin with a Consumer that has the Scope *CanGetConsumers*, and the endpoint's authMode is *UserOrApplication*, the User can access the endpoint even without a personal *CanGetConsumers* Entitlement (because the Consumer's Scope is sufficient). +| +|The authMode is set in the ResourceDoc definition for each endpoint, for example: +| +|``` +|resourceDocs += ResourceDoc( +| getConsumers, +| implementedInApiVersion, +| nameOf(getConsumers), +| "GET", +| "/management/consumers", +| "Get Consumers", +| ..., +| Some(List(canGetConsumers)), +| authMode = UserOrApplication +|) +|``` +| +|If authMode is not specified, it defaults to UserOnly. +| +|Note: If the property *require_scopes_for_all_roles* is set to true, all endpoints behave as *UserAndApplication* regardless of their configured authMode. +| +|See also: [Access Control](/index#API.Access-Control), [Scopes](/index#group-Scope), [Roles](/index#group-Role) +| +|""" + ) + val justInTimeEntitlements : String = if (APIUtil.getPropsAsBoolValue("create_just_in_time_entitlements", false)) {"Just in Time Entitlements are ENABLED on this instance."} else {"Just in Time Entitlements are NOT enabled on this instance."} From 52d17de507b00191f8b2422d259cd47f27388fab Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 14 Mar 2026 01:40:44 +0100 Subject: [PATCH 09/11] Fixtests: For authMode=ApplicationOrUser error message is different --- obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala | 2 +- .../src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala index b3e6b36bde..f6b1d2787a 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala @@ -27,7 +27,7 @@ class GetOidcClientTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.ApplicationNotIdentified) } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala index 2730606d07..38e80f900f 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala @@ -32,7 +32,7 @@ class VerifyOidcClientTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.ApplicationNotIdentified) } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { From 3618529f597be03651f2cc0e1f5c54ca2d20a597 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 14 Mar 2026 01:47:27 +0100 Subject: [PATCH 10/11] Resource Doc authMode == UserOrApplication shows extra info and link to glossary item --- obp-api/src/main/scala/code/api/util/APIUtil.scala | 8 ++++++++ 1 file changed, 8 insertions(+) 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 6594cdae45..3bf6cac629 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1624,6 +1624,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ if (authMode == ApplicationOnly) { errorResponseBodies ?-= AuthenticatedUserIsRequired } + if (authMode == UserOrApplication) { + description += + s""" + | + |This endpoint supports **User OR Application** authentication. You can authenticate either as a logged-in User (with Entitlements) or as an Application using a Consumer Key (with Scopes). + |See ${Glossary.getGlossaryItemLink("API.Endpoint Auth Modes")} for more information. + |""" + } case UserAndApplication => errorResponseBodies ?+= AuthenticatedUserIsRequired errorResponseBodies ?+= ApplicationNotIdentified From 43590062cc89fa0db58d6728de9e77b635f57be6 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 14 Mar 2026 06:22:08 +0100 Subject: [PATCH 11/11] Fix ConsumerTest.scala for authMode=UserOrApplication --- obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala index 0bbc20c6ea..9f337ba42c 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala @@ -28,7 +28,7 @@ package code.api.v5_1_0 import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ -import code.api.util.ErrorMessages.{InvalidJsonFormat, AuthenticatedUserIsRequired} +import code.api.util.ErrorMessages.{InvalidJsonFormat, AuthenticatedUserIsRequired, ApplicationNotIdentified} import code.api.v3_1_0.ConsumerJsonV310 import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 import code.entitlement.Entitlement @@ -87,8 +87,9 @@ class ConsumerTest extends V510ServerSetup { responseApiEndpoint3.code should equal(401) responseApiEndpoint4.code should equal(401) responseApiEndpoint6.code should equal(401) - responseApiEndpoint1.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) - responseApiEndpoint2.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) + // createConsumer and getConsumers use authMode=UserOrApplication, so unauthenticated requests get ApplicationNotIdentified + responseApiEndpoint1.body.toString contains(s"$ApplicationNotIdentified") should be (true) + responseApiEndpoint2.body.toString contains(s"$ApplicationNotIdentified") should be (true) responseApiEndpoint3.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) responseApiEndpoint4.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true) responseApiEndpoint6.body.toString contains(s"$AuthenticatedUserIsRequired") should be (true)