Skip to content

Commit f5e63c9

Browse files
authored
Merge pull request #2732 from simonredfern/develop
authMode on Consumer / OIDC related endpoints + Counterparty Attributes
2 parents 809c03d + 4359006 commit f5e63c9

File tree

22 files changed

+1024
-15
lines changed

22 files changed

+1024
-15
lines changed

obp-api/src/main/resources/docs/introductory_system_documentation.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,56 @@ Adapters listen to message queues or remote calls, parse incoming messages accor
12941294
- Adapter in Go for high-performance transaction processing
12951295
- Adapter in Scala for Akka-based distributed systems
12961296

1297+
**Testing Adapters with Connector Endpoints:**
1298+
1299+
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.
1300+
1301+
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.
1302+
1303+
```properties
1304+
# Export a connector's methods as REST endpoints for development/testing
1305+
# Set this to the connector name you are building an adapter for:
1306+
connector.name.export.as.endpoints=rabbitmq_vOct2024
1307+
```
1308+
1309+
**Validation rules:**
1310+
- If `connector=star`, the value must match one of the connectors listed in `starConnector_supported_types`
1311+
- If `connector=mapped`, the value can be `mapped`
1312+
- Otherwise, the value must match the `connector` props value (e.g. if `connector=rest_vMar2019`, set `connector.name.export.as.endpoints=rest_vMar2019`)
1313+
1314+
**Access control:** Calling these endpoints requires the `CanGetConnectorEndpoint` entitlement.
1315+
1316+
**Debugging Adapters with Connector Traces:**
1317+
1318+
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.
1319+
1320+
Enable connector traces with:
1321+
1322+
```properties
1323+
write_connector_trace=true
1324+
```
1325+
1326+
Each trace records:
1327+
- **correlationId** — links the trace to the originating API request
1328+
- **connectorName** — which connector was used (e.g. `rabbitmq_vOct2024`)
1329+
- **functionName** — the connector method called (e.g. `getBank`, `getBankAccount`)
1330+
- **bankId** — the bank identifier, if applicable
1331+
- **outboundMessage** — full serialized request parameters sent to the adapter
1332+
- **inboundMessage** — full serialized response received from the adapter
1333+
- **duration** — call duration in milliseconds
1334+
- **isSuccessful** — whether the call succeeded
1335+
- **userId**, **httpVerb**, **url** — context about the originating API request
1336+
1337+
Traces can be retrieved via the API:
1338+
1339+
```
1340+
GET /obp/v6.0.0/management/connector/traces
1341+
```
1342+
1343+
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.
1344+
1345+
There is also a **Connector Traces** page in **API Manager** which provides a UI for browsing and filtering connector traces.
1346+
12971347
---
12981348

12991349
### 3.13 Message Docs

obp-api/src/main/resources/props/sample.props.template

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,17 @@ regulated_entities = []
16551655
# oidc_operator_initial_password=...
16561656
# oidc_operator_email=...
16571657

1658+
# Bootstrap OIDC Operator Consumer
1659+
# Given the following key and secret, OBP will create a consumer if it does not already exist.
1660+
# This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient
1661+
# This allows OBP-OIDC to authenticate as an application and manage consumers via the API.
1662+
# Note: If you use this, you may not need the Bootstrap OIDC Operator User above,
1663+
# depending on how OBP-OIDC implements its authentication.
1664+
# If you want to use this feature, please set up both values properly at the same time.
1665+
# Both values must be between 10 and 250 characters.
1666+
# oidc_operator_consumer_key=...
1667+
# oidc_operator_consumer_secret=...
1668+
16581669

16591670
## Ethereum Connector Configuration
16601671
## ================================

obp-api/src/main/scala/bootstrap/liftweb/Boot.scala

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import code.cards.{MappedPhysicalCard, PinReset}
7373
import code.connectormethod.ConnectorMethod
7474
import code.consent.{ConsentRequest, MappedConsent}
7575
import code.consumer.Consumers
76+
import code.model.Consumer
7677
import code.context.{MappedConsentAuthContext, MappedUserAuthContext, MappedUserAuthContextUpdate}
7778
import code.counterpartylimit.CounterpartyLimit
7879
import code.crm.MappedCrmEvent
@@ -121,8 +122,9 @@ import code.products.MappedProduct
121122
import code.ratelimiting.RateLimiting
122123
import code.regulatedentities.MappedRegulatedEntity
123124
import code.regulatedentities.attribute.RegulatedEntityAttribute
125+
import code.counterpartyattribute.{CounterpartyAttribute => CounterpartyAttributeMapper}
124126
import code.scheduler._
125-
import code.scope.{MappedScope, MappedUserScope}
127+
import code.scope.{MappedScope, MappedUserScope, Scope}
126128
import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketPayment}
127129
import code.socialmedia.MappedSocialMedia
128130
import code.standingorders.StandingOrder
@@ -337,6 +339,8 @@ class Boot extends MdcLoggable {
337339

338340
createBootstrapOidcOperatorUser()
339341

342+
createBootstrapOidcOperatorConsumer()
343+
340344
//launch the scheduler to clean the database from the expired tokens and nonces, 1 hour
341345
DataBaseCleanerScheduler.start(intervalInSeconds = 60*60)
342346

@@ -1095,6 +1099,71 @@ class Boot extends MdcLoggable {
10951099
}
10961100
}
10971101

1102+
/**
1103+
* Bootstrap OIDC Operator Consumer
1104+
* Given the following key and secret, OBP will create a consumer *if it does not exist already*.
1105+
* This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient
1106+
* This allows OBP-OIDC to authenticate as an application (without a user) and manage consumers via the API.
1107+
*/
1108+
private def createBootstrapOidcOperatorConsumer() = {
1109+
1110+
val oidcOperatorConsumerKey = APIUtil.getPropsValue("oidc_operator_consumer_key", "")
1111+
val oidcOperatorConsumerSecret = APIUtil.getPropsValue("oidc_operator_consumer_secret", "")
1112+
1113+
val isPropsNotSetProperly = oidcOperatorConsumerKey == "" || oidcOperatorConsumerSecret == ""
1114+
1115+
if (isPropsNotSetProperly) {
1116+
logger.info(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_key and/or oidc_operator_consumer_secret props are not set, skipping")
1117+
} else if (oidcOperatorConsumerKey.length < 10) {
1118+
logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_key is too short (${oidcOperatorConsumerKey.length} chars, minimum 10), skipping")
1119+
} else if (oidcOperatorConsumerKey.length > 250) {
1120+
logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_key is too long (${oidcOperatorConsumerKey.length} chars, maximum 250), skipping")
1121+
} else if (oidcOperatorConsumerSecret.length < 10) {
1122+
logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_secret is too short (${oidcOperatorConsumerSecret.length} chars, minimum 10), skipping")
1123+
} else if (oidcOperatorConsumerSecret.length > 250) {
1124+
logger.error(s"createBootstrapOidcOperatorConsumer says: oidc_operator_consumer_secret is too long (${oidcOperatorConsumerSecret.length} chars, maximum 250), skipping")
1125+
} else {
1126+
val existingConsumer = Consumers.consumers.vend.getConsumerByConsumerKey(oidcOperatorConsumerKey)
1127+
1128+
if (existingConsumer.isDefined) {
1129+
logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer with key ${oidcOperatorConsumerKey} already exists, skipping creation")
1130+
} else {
1131+
saveOidcOperatorConsumer(oidcOperatorConsumerKey, oidcOperatorConsumerSecret)
1132+
}
1133+
}
1134+
}
1135+
1136+
// Separate method to create and save the OIDC operator consumer.
1137+
// Uses Consumer.create directly (not Consumers.consumers.vend.createConsumer)
1138+
// to avoid S.? calls during Boot (Lift's S scope is not initialized at boot time).
1139+
private def saveOidcOperatorConsumer(consumerKey: String, consumerSecret: String): Unit = {
1140+
// Create consumer directly, skipping validate (which calls S.? and fails during Boot)
1141+
val c = Consumer.create
1142+
.key(consumerKey)
1143+
.secret(consumerSecret)
1144+
.name("OIDC Operator Consumer")
1145+
c.isActive(true) // MappedBoolean.apply returns Mapper, must be separate statement
1146+
c.description("Bootstrap consumer for OBP-OIDC to manage consumers via the API") // MappedText.apply returns Mapper, must be separate statement
1147+
1148+
val consumerBox = tryo(c.saveMe())
1149+
1150+
consumerBox match {
1151+
case Full(consumer) =>
1152+
logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer created successfully with consumer_id: ${consumer.consumerId.get}")
1153+
val scopes = List(CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient)
1154+
scopes.foreach { role =>
1155+
val resultBox = Scope.scope.vend.addScope("", consumer.id.get.toString, role.toString)
1156+
if (resultBox.isEmpty) {
1157+
logger.error(s"createBootstrapOidcOperatorConsumer says: Error granting scope ${role}: ${resultBox}")
1158+
}
1159+
}
1160+
case net.liftweb.common.Failure(msg, exception, _) =>
1161+
logger.error(s"createBootstrapOidcOperatorConsumer says: Error creating consumer: $msg ${exception.map(_.getMessage).openOr("")}")
1162+
case _ =>
1163+
logger.error("createBootstrapOidcOperatorConsumer says: Error creating consumer (unknown error)")
1164+
}
1165+
}
1166+
10981167
LiftRules.statelessDispatch.append(aliveCheck)
10991168

11001169
}
@@ -1225,6 +1294,7 @@ object ToSchemify {
12251294
CustomerAccountLink,
12261295
TransactionIdMapping,
12271296
RegulatedEntityAttribute,
1297+
CounterpartyAttributeMapper,
12281298
BankAccountBalance,
12291299
Group,
12301300
AccountAccessRequest

obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6080,7 +6080,27 @@ object SwaggerDefinitionsJSON {
60806080
lazy val regulatedEntityAttributesJsonV510 = RegulatedEntityAttributesJsonV510(
60816081
List(regulatedEntityAttributeResponseJsonV510)
60826082
)
6083-
6083+
6084+
lazy val counterpartyAttributeRequestJsonV600 = CounterpartyAttributeRequestJsonV600(
6085+
name = "TAX_NUMBER",
6086+
attribute_type = "STRING",
6087+
value = "123456789",
6088+
is_active = Some(true)
6089+
)
6090+
6091+
lazy val counterpartyAttributeResponseJsonV600 = CounterpartyAttributeResponseJsonV600(
6092+
counterparty_id = counterpartyIdExample.value,
6093+
counterparty_attribute_id = "613c83ea-80f9-4560-8404-b9cd4ec42a7f",
6094+
name = "TAX_NUMBER",
6095+
attribute_type = "STRING",
6096+
value = "123456789",
6097+
is_active = Some(true)
6098+
)
6099+
6100+
lazy val counterpartyAttributesJsonV600 = CounterpartyAttributesJsonV600(
6101+
List(counterpartyAttributeResponseJsonV600)
6102+
)
6103+
60846104
lazy val bankAccountBalanceRequestJsonV510 = BankAccountBalanceRequestJsonV510(
60856105
balance_type = balanceTypeExample.value,
60866106
balance_amount = balanceAmountExample.value

obp-api/src/main/scala/code/api/util/APIUtil.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,6 +1624,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
16241624
if (authMode == ApplicationOnly) {
16251625
errorResponseBodies ?-= AuthenticatedUserIsRequired
16261626
}
1627+
if (authMode == UserOrApplication) {
1628+
description +=
1629+
s"""
1630+
|
1631+
|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).
1632+
|See ${Glossary.getGlossaryItemLink("API.Endpoint Auth Modes")} for more information.
1633+
|"""
1634+
}
16271635
case UserAndApplication =>
16281636
errorResponseBodies ?+= AuthenticatedUserIsRequired
16291637
errorResponseBodies ?+= ApplicationNotIdentified

obp-api/src/main/scala/code/api/util/ApiRole.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,21 @@ object ApiRole extends MdcLoggable{
731731
case class CanDeleteRegulatedEntityAttribute(requiresBankId: Boolean = false) extends ApiRole
732732
lazy val canDeleteRegulatedEntityAttribute = CanDeleteRegulatedEntityAttribute()
733733

734+
case class CanGetCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole
735+
lazy val canGetCounterpartyAttribute = CanGetCounterpartyAttribute()
736+
737+
case class CanGetCounterpartyAttributes(requiresBankId: Boolean = false) extends ApiRole
738+
lazy val canGetCounterpartyAttributes = CanGetCounterpartyAttributes()
739+
740+
case class CanCreateCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole
741+
lazy val canCreateCounterpartyAttribute = CanCreateCounterpartyAttribute()
742+
743+
case class CanUpdateCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole
744+
lazy val canUpdateCounterpartyAttribute = CanUpdateCounterpartyAttribute()
745+
746+
case class CanDeleteCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole
747+
lazy val canDeleteCounterpartyAttribute = CanDeleteCounterpartyAttribute()
748+
734749

735750
case class CanGetMethodRoutings(requiresBankId: Boolean = false) extends ApiRole
736751
lazy val canGetMethodRoutings = CanGetMethodRoutings()

obp-api/src/main/scala/code/api/util/ApiTag.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ object ApiTag {
4949
val apiTagScope = ResourceDocTag("Scope")
5050
val apiTagOwnerRequired = ResourceDocTag("OwnerViewRequired")
5151
val apiTagCounterparty = ResourceDocTag("Counterparty")
52+
val apiTagCounterpartyAttribute = ResourceDocTag("Counterparty-Attribute")
5253
val apiTagKyc = ResourceDocTag("KYC")
5354
val apiTagCustomer = ResourceDocTag("Customer")
5455
val apiTagRetailCustomer = ResourceDocTag("Retail-Customer")

obp-api/src/main/scala/code/api/util/DoobieQueries.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ object DoobieQueries {
2020
*/
2121
def getDistinctProviders: List[String] = {
2222
val query: ConnectionIO[List[String]] =
23-
sql"""SELECT DISTINCT provider_ FROM resourceuser ORDER BY provider_"""
23+
sql"""SELECT DISTINCT provider_ FROM resourceuser WHERE provider_ IS NOT NULL ORDER BY provider_"""
2424
.query[String]
2525
.to[List]
2626

0 commit comments

Comments
 (0)