Merge pull request #2675 from simonredfern/develop

v6.0.0 get transactions, get OIDC client and verify
This commit is contained in:
Simon Redfern 2026-01-30 09:25:03 +01:00 committed by GitHub
commit 8900d60ead
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 571 additions and 8 deletions

View File

@ -282,6 +282,12 @@ object ApiRole extends MdcLoggable{
case class CanGetCurrentConsumer(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetCurrentConsumer = CanGetCurrentConsumer()
case class CanVerifyOidcClient(requiresBankId: Boolean = false) extends ApiRole
lazy val canVerifyOidcClient = CanVerifyOidcClient()
case class CanGetOidcClient(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetOidcClient = CanGetOidcClient()
case class CanCreateTransactionType(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateTransactionType = CanCreateTransactionType()

View File

@ -23,20 +23,22 @@ import code.api.v3_0_0.JSONFactory300
import code.api.v3_0_0.JSONFactory300.createAggregateMetricJson
import code.api.v2_0_0.JSONFactory200
import code.api.v3_1_0.{JSONFactory310, PostCustomerNumberJsonV310}
import code.api.v4_0_0.CallLimitPostJsonV400
import code.api.v1_2_1.{AccountHolderJSON, BankRoutingJsonV121, TransactionDetailsJSON}
import code.api.v4_0_0.{BankAttributeBankResponseJsonV400, CallLimitPostJsonV400}
import code.api.v4_0_0.JSONFactory400.createCallsLimitJson
import code.api.v5_0_0.JSONFactory500
import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500}
import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510}
import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo}
import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson}
import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, DynamicEntityLinksJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, PostVerifyUserCredentialsJsonV600, RedisCacheStatusJsonV600, RelatedLinkJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600}
import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, DynamicEntityLinksJsonV600, ExecuteAbacRuleJsonV600, GetOidcClientResponseJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, PostVerifyUserCredentialsJsonV600, RedisCacheStatusJsonV600, RelatedLinkJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600, VerifyOidcClientRequestJsonV600, VerifyOidcClientResponseJsonV600}
import code.api.v6_0_0.OBPAPI6_0_0
import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider}
import code.metrics.APIMetrics
import code.bankconnectors.{Connector, LocalMappedConnectorInternal}
import code.bankconnectors.storedprocedure.StoredProcedureUtils
import code.bankconnectors.LocalMappedConnectorInternal._
import code.consumer.Consumers
import code.entitlement.Entitlement
import code.loginattempts.LoginAttempt
import code.model._
@ -917,6 +919,174 @@ trait APIMethods600 {
}
}
staticResourceDocs += ResourceDoc(
getBanks,
implementedInApiVersion,
nameOf(getBanks),
"GET",
"/banks",
"Get Banks",
"""Get banks on this API instance
|Returns a list of banks supported on this server:
|
|- bank_id used as parameter in URLs
|- Short and full name of bank
|- Logo URL
|- Website
|
|User Authentication is Optional. The User need not be logged in.
|""",
EmptyBody,
BanksJsonV600(List(BankJsonV600(
bank_id = "gh.29.uk",
bank_code = "bank_code",
full_name = "full_name",
logo = "logo",
website = "www.openbankproject.com",
bank_routings = List(BankRoutingJsonV121("OBP", "gh.29.uk")),
attributes = Some(List(BankAttributeBankResponseJsonV400("OVERDRAFT_LIMIT", "1000")))
))),
List(UnknownError),
apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil
)
lazy val getBanks: OBPEndpoint = {
case "banks" :: Nil JsonGet _ => { cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(banks, callContext) <- NewStyle.function.getBanks(cc.callContext)
} yield {
(JSONFactory600.createBanksJsonV600(banks), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
getBank,
implementedInApiVersion,
nameOf(getBank),
"GET",
"/banks/BANK_ID",
"Get Bank",
"""Get the bank specified by BANK_ID
|Returns information about a single bank specified by BANK_ID including:
|
|- bank_id: The unique identifier of this bank
|- Short and full name of bank
|- Logo URL
|- Website
|""",
EmptyBody,
BankJsonV600(
bank_id = "gh.29.uk",
bank_code = "bank_code",
full_name = "full_name",
logo = "logo",
website = "www.openbankproject.com",
bank_routings = List(BankRoutingJsonV121("OBP", "gh.29.uk")),
attributes = Some(List(BankAttributeBankResponseJsonV400("OVERDRAFT_LIMIT", "1000")))
),
List(UnknownError, BankNotFound),
apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil
)
lazy val getBank: OBPEndpoint = {
case "banks" :: BankId(bankId) :: Nil JsonGet _ => { cc =>
implicit val ec = EndpointContext(Some(cc))
for {
(bank, callContext) <- NewStyle.function.getBank(bankId, cc.callContext)
(attributes, callContext) <- NewStyle.function.getBankAttributesByBank(bankId, callContext)
} yield {
(JSONFactory600.createBankJsonV600(bank, attributes), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
getTransactionsForBankAccount,
implementedInApiVersion,
nameOf(getTransactionsForBankAccount),
"GET",
"/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions",
"Get Transactions for Account (Full)",
s"""Returns transactions list of the account specified by ACCOUNT_ID and [moderated](#1_2_1-getViewsForBankAccount) by the view (VIEW_ID).
|
|${userAuthenticationMessage(false)}
|
|Authentication is required if the view is not public.
|
|${urlParametersDocument(true, true)}
|
|**Note:** This v6.0.0 endpoint returns `bank_id` directly in both `this_account` and `other_account` objects,
|making it easier to identify which bank each account belongs to without parsing the `bank_routing` object.
|
|""",
EmptyBody,
TransactionsJsonV600(List(TransactionJsonV600(
transaction_id = "123",
this_account = ThisAccountJsonV600(
bank_id = "gh.29.uk",
account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0",
bank_routing = BankRoutingJsonV121("OBP", "gh.29.uk"),
account_routings = List(AccountRoutingJsonV121("OBP", "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")),
holders = List(AccountHolderJSON("John Doe", false))
),
other_account = OtherAccountJsonV600(
bank_id = "other.bank.uk",
account_id = "counterparty-123",
holder = AccountHolderJSON("Jane Smith", false),
bank_routing = BankRoutingJsonV121("OBP", "other.bank.uk"),
account_routings = List(AccountRoutingJsonV121("OBP", "counterparty-123")),
metadata = null
),
details = TransactionDetailsJSON(
`type` = "SEPA",
description = "Payment for services",
posted = new java.util.Date(),
completed = new java.util.Date(),
new_balance = AmountOfMoneyJsonV121("EUR", "1000.00"),
value = AmountOfMoneyJsonV121("EUR", "100.00")
),
metadata = null,
transaction_attributes = Nil
))),
List(
FilterSortDirectionError,
FilterOffersetError,
FilterLimitError,
FilterDateFormatError,
AuthenticatedUserIsRequired,
BankAccountNotFound,
ViewNotFound,
UnknownError
),
List(apiTagTransaction, apiTagAccount)
)
lazy val getTransactionsForBankAccount: OBPEndpoint = {
case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transactions" :: Nil JsonGet req => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
(user, callContext) <- authenticatedAccess(cc)
(bank, callContext) <- NewStyle.function.getBank(bankId, callContext)
(bankAccount, callContext) <- NewStyle.function.checkBankAccountExists(bankId, accountId, callContext)
view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), user, callContext)
(params, callContext) <- createQueriesByHttpParamsFuture(callContext.get.requestHeaders, callContext)
(transactions, callContext) <- bankAccount.getModeratedTransactionsFuture(bank, user, view, callContext, params) map {
connectorEmptyResponse(_, callContext)
}
moderatedTransactionsWithAttributes <- Future.sequence(transactions.map(transaction =>
NewStyle.function.getTransactionAttributes(
bankId,
transaction.id,
cc.callContext: Option[CallContext]).map(attributes => code.api.v3_0_0.ModeratedTransactionWithAttributes(transaction, attributes._1))
))
} yield {
(JSONFactory600.createTransactionsJsonV600(moderatedTransactionsWithAttributes), HttpCode.`200`(callContext))
}
}
}
lazy val getCurrentConsumer: OBPEndpoint = {
case "consumers" :: "current" :: Nil JsonGet _ => {
cc => {
@ -1644,8 +1814,9 @@ trait APIMethods600 {
json.extract[PostBankJson600]
}
// TODO: Improve this error message to not hardcode "16" - should reference the max length from checkOptionalShortString function
checkShortStringValue = APIUtil.checkOptionalShortString(postJson.bank_id)
_ <- Helper.booleanToFuture(failMsg = s"$checkShortStringValue.", cc = cc.callContext) {
_ <- Helper.booleanToFuture(failMsg = s"$InvalidJsonFormat BANK_ID: $checkShortStringValue BANK_ID must contain only characters A-Z, a-z, 0-9, -, _, . and be max 16 characters.", cc = cc.callContext) {
checkShortStringValue == SILENCE_IS_GOLDEN
}
@ -7221,6 +7392,135 @@ trait APIMethods600 {
}
}
staticResourceDocs += ResourceDoc(
verifyOidcClient,
implementedInApiVersion,
nameOf(verifyOidcClient),
"POST",
"/oidc/clients/verify",
"Verify OIDC Client",
s"""Verifies an OIDC/OAuth2 client's credentials.
|
|Returns `valid: true` if the client_id and client_secret match an active consumer.
|Also returns the consumer_id and redirect_uris for use by the OIDC provider.
|
|${userAuthenticationMessage(true)}
|""",
VerifyOidcClientRequestJsonV600(
client_id = "abc123def456",
client_secret = "supersecret123"
),
VerifyOidcClientResponseJsonV600(
valid = true,
client_id = Some("abc123def456"),
consumer_id = Some("7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh"),
redirect_uris = Some(List("https://app.example.com/callback"))
),
List(
$AuthenticatedUserIsRequired,
UserHasMissingRoles,
InvalidJsonFormat,
UnknownError
),
List(apiTagOIDC, apiTagConsumer, apiTagOAuth),
Some(List(canVerifyOidcClient))
)
lazy val verifyOidcClient: OBPEndpoint = {
case "oidc" :: "clients" :: "verify" :: Nil JsonPost json -> _ => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- authenticatedAccess(cc)
_ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit))
else NewStyle.function.hasEntitlement("", u.userId, canVerifyOidcClient, callContext)
postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the VerifyOidcClientRequestJsonV600", 400, callContext) {
json.extract[VerifyOidcClientRequestJsonV600]
}
consumerBox <- Future {
Consumers.consumers.vend.getConsumerByConsumerKey(postedData.client_id)
}
} yield {
consumerBox match {
case Full(consumer) if consumer.isActive.get && consumer.secret.get == postedData.client_secret =>
val redirectUris = Option(consumer.redirectURL.get)
.filter(_.nonEmpty)
.map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList)
(VerifyOidcClientResponseJsonV600(
valid = true,
client_id = Some(postedData.client_id),
consumer_id = Some(consumer.consumerId.get),
redirect_uris = redirectUris
), HttpCode.`200`(callContext))
case _ =>
(VerifyOidcClientResponseJsonV600(valid = false), HttpCode.`200`(callContext))
}
}
}
}
staticResourceDocs += ResourceDoc(
getOidcClient,
implementedInApiVersion,
nameOf(getOidcClient),
"GET",
"/oidc/clients/CLIENT_ID",
"Get OIDC Client",
s"""Gets an OIDC/OAuth2 client's metadata by client_id.
|
|Returns client information including name, consumer_id, redirect_uris, and enabled status.
|This endpoint does not verify the client secret - use POST /oidc/clients/verify for authentication.
|
|${userAuthenticationMessage(true)}
|""",
EmptyBody,
GetOidcClientResponseJsonV600(
client_id = "abc123def456",
client_name = "My Application",
consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh",
redirect_uris = List("https://app.example.com/callback"),
enabled = true
),
List(
$AuthenticatedUserIsRequired,
UserHasMissingRoles,
UnknownError
),
List(apiTagOIDC, apiTagConsumer, apiTagOAuth),
Some(List(canGetOidcClient))
)
lazy val getOidcClient: OBPEndpoint = {
case "oidc" :: "clients" :: clientId :: Nil JsonGet _ => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
(Full(u), callContext) <- authenticatedAccess(cc)
_ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit))
else NewStyle.function.hasEntitlement("", u.userId, canGetOidcClient, callContext)
consumerBox <- Future {
Consumers.consumers.vend.getConsumerByConsumerKey(clientId)
}
consumer <- NewStyle.function.tryons(s"OBP-OIDC-003: Client not found: $clientId", 404, callContext) {
consumerBox match {
case Full(c) => c
case _ => throw new RuntimeException("Client not found")
}
}
} yield {
val redirectUris = Option(consumer.redirectURL.get)
.filter(_.nonEmpty)
.map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList)
.getOrElse(List.empty)
(GetOidcClientResponseJsonV600(
client_id = clientId,
client_name = consumer.name.get,
consumer_id = consumer.consumerId.get,
redirect_uris = redirectUris,
enabled = consumer.isActive.get
), HttpCode.`200`(callContext))
}
}
}
}
}

View File

@ -16,17 +16,19 @@ package code.api.v6_0_0
import code.api.util.APIUtil.stringOrNull
import code.api.util.RateLimitingPeriod.LimitCallPeriod
import code.api.util._
import code.api.v1_2_1.BankRoutingJsonV121
import code.api.v1_2_1.{AccountHolderJSON, BankRoutingJsonV121, OtherAccountMetadataJSON, TransactionDetailsJSON, TransactionMetadataJSON}
import code.api.v1_4_0.JSONFactory1_4_0.CustomerFaceImageJson
import code.api.v2_0_0.{EntitlementJSONs, JSONFactory200}
import code.api.v2_1_0.CustomerCreditRatingJSON
import code.api.v3_0_0.{
CustomerAttributeResponseJsonV300,
ModeratedTransactionWithAttributes,
UserJsonV300,
ViewJSON300,
ViewsJSON300
}
import code.api.v3_1_0.{RateLimit, RedisCallLimitJson}
import code.api.v3_1_0.{AccountAttributeResponseJson, RateLimit, RedisCallLimitJson}
import code.api.v4_0_0.TransactionAttributeResponseJson
import code.api.v4_0_0.{BankAttributeBankResponseJsonV400, UserAgreementJson}
import code.entitlement.Entitlement
import code.loginattempts.LoginAttempt
@ -76,6 +78,28 @@ case class CurrentConsumerJsonV600(
call_counters: RedisCallCountersJsonV600
)
// OIDC Client Verification models (V600)
case class VerifyOidcClientRequestJsonV600(
client_id: String,
client_secret: String
)
case class VerifyOidcClientResponseJsonV600(
valid: Boolean,
client_id: Option[String] = None,
consumer_id: Option[String] = None,
redirect_uris: Option[List[String]] = None
)
// OIDC Client Get (metadata lookup without secret verification)
case class GetOidcClientResponseJsonV600(
client_id: String,
client_name: String,
consumer_id: String,
redirect_uris: List[String],
enabled: Boolean
)
case class CallLimitPostJsonV600(
from_date: java.util.Date,
to_date: java.util.Date,
@ -334,6 +358,18 @@ case class StoredProcedureConnectorHealthJsonV600(
error_message: Option[String]
)
case class BankJsonV600(
bank_id: String,
bank_code: String,
full_name: String,
logo: String,
website: String,
bank_routings: List[BankRoutingJsonV121],
attributes: Option[List[BankAttributeBankResponseJsonV400]]
)
case class BanksJsonV600(banks: List[BankJsonV600])
case class PostCustomerJsonV600(
legal_name: String,
customer_number: Option[String] = None,
@ -515,6 +551,37 @@ case class AbacPoliciesJsonV600(
policies: List[AbacPolicyJsonV600]
)
// Transaction JSON structures for v6.0.0 - with bank_id included directly
case class ThisAccountJsonV600(
bank_id: String,
account_id: String,
bank_routing: BankRoutingJsonV121,
account_routings: List[AccountRoutingJsonV121],
holders: List[AccountHolderJSON]
)
case class OtherAccountJsonV600(
bank_id: String,
account_id: String,
holder: AccountHolderJSON,
bank_routing: BankRoutingJsonV121,
account_routings: List[AccountRoutingJsonV121],
metadata: OtherAccountMetadataJSON
)
case class TransactionJsonV600(
transaction_id: String,
this_account: ThisAccountJsonV600,
other_account: OtherAccountJsonV600,
details: TransactionDetailsJSON,
metadata: TransactionMetadataJSON,
transaction_attributes: List[TransactionAttributeResponseJson]
)
case class TransactionsJsonV600(
transactions: List[TransactionJsonV600]
)
// HATEOAS-style links for dynamic entity discoverability
case class RelatedLinkJsonV600(rel: String, href: String, method: String)
case class DynamicEntityLinksJsonV600(
@ -1397,6 +1464,34 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
)
}
def createBankJsonV600(bank: Bank, attributes: List[BankAttributeTrait] = Nil): BankJsonV600 = {
val obp = BankRoutingJsonV121("OBP", bank.bankId.value)
val bic = BankRoutingJsonV121("BIC", bank.swiftBic)
val routings = bank.bankRoutingScheme match {
case "OBP" => bic :: BankRoutingJsonV121(bank.bankRoutingScheme, bank.bankRoutingAddress) :: Nil
case "BIC" => obp :: BankRoutingJsonV121(bank.bankRoutingScheme, bank.bankRoutingAddress) :: Nil
case _ => obp :: bic :: BankRoutingJsonV121(bank.bankRoutingScheme, bank.bankRoutingAddress) :: Nil
}
BankJsonV600(
bank_id = stringOrNull(bank.bankId.value),
bank_code = stringOrNull(bank.shortName),
full_name = stringOrNull(bank.fullName),
logo = stringOrNull(bank.logoUrl),
website = stringOrNull(bank.websiteUrl),
bank_routings = routings.filter(a => stringOrNull(a.address) != null),
attributes = Option(
attributes.filter(_.isActive == Some(true)).map(a => BankAttributeBankResponseJsonV400(
name = a.name,
value = a.value)
)
)
)
}
def createBanksJsonV600(banks: List[Bank]): BanksJsonV600 = {
BanksJsonV600(banks.map(bank => createBankJsonV600(bank, Nil)))
}
/**
* Create v6.0.0 response for GET /my/dynamic-entities
*
@ -1557,4 +1652,75 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
)
}
// Transaction v6.0.0 factory methods
import code.api.util.APIUtil.stringOptionOrNull
import code.api.v1_2_1.JSONFactory.{createAmountOfMoneyJSON, createTransactionCommentJSON, createTransactionTagJSON, createTransactionImageJSON, createLocationJSON, createAccountHolderJSON}
import code.api.v3_0_0.JSONFactory300.createOtherAccountMetaDataJSON
import code.api.v4_0_0.JSONFactory400.createTransactionAttributeJson
import code.model.{ModeratedBankAccount, ModeratedOtherBankAccount, ModeratedTransaction, ModeratedTransactionMetadata}
def createTransactionsJsonV600(moderatedTransactionsWithAttributes: List[ModeratedTransactionWithAttributes]): TransactionsJsonV600 = {
TransactionsJsonV600(moderatedTransactionsWithAttributes.map(t => createTransactionJsonV600(t.transaction, t.transactionAttributes)))
}
def createTransactionJsonV600(transaction: ModeratedTransaction, transactionAttributes: List[TransactionAttribute]): TransactionJsonV600 = {
TransactionJsonV600(
transaction_id = transaction.id.value,
this_account = transaction.bankAccount.map(createThisAccountJsonV600).getOrElse(null),
other_account = transaction.otherBankAccount.map(createOtherAccountJsonV600).getOrElse(null),
details = createTransactionDetailsJsonV600(transaction),
metadata = transaction.metadata.map(createTransactionMetadataJsonV600).getOrElse(null),
transaction_attributes = transactionAttributes.map(createTransactionAttributeJson)
)
}
def createThisAccountJsonV600(bankAccount: ModeratedBankAccount): ThisAccountJsonV600 = {
ThisAccountJsonV600(
bank_id = bankAccount.bankId.value,
account_id = bankAccount.accountId.value,
bank_routing = BankRoutingJsonV121(stringOptionOrNull(bankAccount.bankRoutingScheme), stringOptionOrNull(bankAccount.bankRoutingAddress)),
account_routings = List(AccountRoutingJsonV121(stringOptionOrNull(bankAccount.accountRoutingScheme), stringOptionOrNull(bankAccount.accountRoutingAddress))),
holders = bankAccount.owners.map(x => x.toList.map(holder => AccountHolderJSON(name = holder.name, is_alias = false))).getOrElse(null)
)
}
def createOtherAccountJsonV600(bankAccount: ModeratedOtherBankAccount): OtherAccountJsonV600 = {
// Extract bank_id from bank_routing when scheme is "OBP", otherwise use the address as best effort
val bankId = bankAccount.bankRoutingScheme match {
case Some("OBP") => stringOptionOrNull(bankAccount.bankRoutingAddress)
case _ => stringOptionOrNull(bankAccount.bankRoutingAddress) // Best effort - use address
}
OtherAccountJsonV600(
bank_id = bankId,
account_id = bankAccount.id,
holder = createAccountHolderJSON(bankAccount.label.display, bankAccount.isAlias),
bank_routing = BankRoutingJsonV121(stringOptionOrNull(bankAccount.bankRoutingScheme), stringOptionOrNull(bankAccount.bankRoutingAddress)),
account_routings = List(AccountRoutingJsonV121(stringOptionOrNull(bankAccount.accountRoutingScheme), stringOptionOrNull(bankAccount.accountRoutingAddress))),
metadata = bankAccount.metadata.map(createOtherAccountMetaDataJSON).getOrElse(null)
)
}
def createTransactionDetailsJsonV600(transaction: ModeratedTransaction): TransactionDetailsJSON = {
TransactionDetailsJSON(
`type` = stringOptionOrNull(transaction.transactionType),
description = stringOptionOrNull(transaction.description),
posted = transaction.startDate.getOrElse(null),
completed = transaction.finishDate.getOrElse(null),
new_balance = createAmountOfMoneyJSON(transaction.currency, transaction.balance),
value = createAmountOfMoneyJSON(transaction.currency, transaction.amount.map(_.toString))
)
}
def createTransactionMetadataJsonV600(metadata: ModeratedTransactionMetadata): TransactionMetadataJSON = {
TransactionMetadataJSON(
narrative = stringOptionOrNull(metadata.ownerComment),
comments = metadata.comments.map(_.map(createTransactionCommentJSON)).getOrElse(null),
tags = metadata.tags.map(_.map(createTransactionTagJSON)).getOrElse(null),
images = metadata.images.map(_.map(createTransactionImageJSON)).getOrElse(null),
where = metadata.whereTag.map(createLocationJSON).getOrElse(null)
)
}
}

View File

@ -207,16 +207,30 @@ class AuthUser extends MegaProtoUser[AuthUser] with CreatedUpdated with MdcLogga
*/
def valUniqueExternally(msg: => String)(uniqueUsername: String): List[FieldError] ={
if (APIUtil.getPropsAsBoolValue("connector.user.authentication", false)) {
Connector.connector.vend.checkExternalUserExists(uniqueUsername, None).map(_.sub) match {
logger.info(s"valUniqueExternally: calling checkExternalUserExists for username: $uniqueUsername")
val connectorResult = Connector.connector.vend.checkExternalUserExists(uniqueUsername, None)
logger.info(s"valUniqueExternally: checkExternalUserExists returned: ${connectorResult.getClass.getSimpleName}")
connectorResult.map(_.sub) match {
case Full(returnedUsername) => // Get the username via connector
logger.info(s"valUniqueExternally: checkExternalUserExists returned username: $returnedUsername")
if(uniqueUsername == returnedUsername) { // Username is NOT unique
logger.info(s"valUniqueExternally: username $uniqueUsername already exists externally")
List(FieldError(this, Text(msg))) // provide the error message
} else {
} else {
logger.info(s"valUniqueExternally: username $uniqueUsername is unique (returned different: $returnedUsername)")
Nil // All good. Allow username creation
}
case ParamFailure(message,_,_,APIFailure(errorMessage, errorCode)) if errorMessage.contains("NO DATA") => // Cannot get the username via connector
logger.info(s"valUniqueExternally: checkExternalUserExists returned NO DATA for username: $uniqueUsername - allowing creation")
Nil // All good. Allow username creation
case Failure(failureMsg, exception, chain) =>
logger.warn(s"valUniqueExternally: checkExternalUserExists failed for username: $uniqueUsername, message: $failureMsg, exception: ${exception.map(_.getMessage)}, chain: $chain")
List(FieldError(this, Text(msg)))
case Empty =>
logger.warn(s"valUniqueExternally: checkExternalUserExists returned Empty for username: $uniqueUsername")
List(FieldError(this, Text(msg)))
case _ => // Any other case we provide error message
logger.warn(s"valUniqueExternally: checkExternalUserExists returned unexpected result for username: $uniqueUsername")
List(FieldError(this, Text(msg)))
}
} else {
@ -932,8 +946,12 @@ import net.liftweb.util.Helpers._
* @return Return the authUser
*/
def checkExternalUserViaConnector(username: String, password: String):Box[AuthUser] = {
Connector.connector.vend.checkExternalUserCredentials(username, password, None) match {
logger.info(s"checkExternalUserViaConnector: calling checkExternalUserCredentials for username: $username")
val connectorResult = Connector.connector.vend.checkExternalUserCredentials(username, password, None)
logger.info(s"checkExternalUserViaConnector: checkExternalUserCredentials returned: ${connectorResult.getClass.getSimpleName}")
connectorResult match {
case Full(InboundExternalUser(aud, exp, iat, iss, sub, azp, email, emailVerified, name, userAuthContexts)) =>
logger.info(s"checkExternalUserViaConnector: successful response for sub: $sub, iss: $iss, email: $email")
val user = findAuthUserByUsernameAndProvider(sub, iss) match { // Check if the external user is already created locally
case Full(user) if user.validated_? => // Return existing user if found
logger.debug("external user already exists locally, using that one")
@ -969,7 +987,14 @@ import net.liftweb.util.Helpers._
case None => // Do nothing
}
Full(user)
case Failure(msg, exception, chain) =>
logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials failed for username: $username, message: $msg, exception: ${exception.map(_.getMessage)}, chain: $chain")
Empty
case Empty =>
logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials returned Empty for username: $username")
Empty
case _ =>
logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials returned unexpected result for username: $username")
Empty
}
}

View File

@ -6,11 +6,13 @@ import code.api.util.ApiRole.CanCreateBank
import code.api.util.ErrorMessages
import code.api.util.ErrorMessages.UserHasMissingRoles
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 net.liftweb.util.Helpers.randomString
import org.scalatest.Tag
class BankTests extends V600ServerSetup with DefaultUsers {
@ -54,6 +56,70 @@ class BankTests extends V600ServerSetup with DefaultUsers {
response.code should equal(403)
response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanCreateBank)
}
scenario("Successfully create a bank with a 16-character bank_id (max length)", ApiEndpoint1, VersionOfApi) {
// Add the required entitlement
val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateBank.toString)
// Generate a 16-character bank_id (maximum allowed by checkOptionalShortString validation)
val longBankId = "bank." + randomString(11).toLowerCase // 5 + 11 = 16 characters
When("We create a bank with a 16-character bank_id")
val postJson = PostBankJson600(
bank_id = longBankId,
bank_code = "test_code",
full_name = Some("Test Bank with Long ID"),
logo = Some("https://example.com/logo.png"),
website = Some("https://example.com"),
bank_routings = None
)
val request = (v6_0_0_Request / "banks").POST <@ (user1)
val response = try {
makePostRequest(request, write(postJson))
} finally {
// Clean up entitlement
Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement)
}
Then("We should get a 201")
response.code should equal(201)
And("The response should contain the bank with the 16-character bank_id")
val responseJson = response.body
(responseJson \ "bank_id").extract[String] should equal(longBankId)
(responseJson \ "bank_id").extract[String].length should equal(16)
}
scenario("Fail to create a bank with bank_id exceeding 16 characters", ApiEndpoint1, VersionOfApi) {
// Add the required entitlement
val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateBank.toString)
// Generate a 17-character bank_id (exceeds maximum of 16)
val tooLongBankId = "bank." + randomString(12).toLowerCase // 5 + 12 = 17 characters
When("We try to create a bank with a 17-character bank_id")
val postJson = PostBankJson600(
bank_id = tooLongBankId,
bank_code = "test_code",
full_name = Some("Test Bank with Too Long ID"),
logo = Some("https://example.com/logo.png"),
website = Some("https://example.com"),
bank_routings = None
)
val request = (v6_0_0_Request / "banks").POST <@ (user1)
val response = try {
makePostRequest(request, write(postJson))
} finally {
// Clean up entitlement
Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement)
}
Then("We should get a 400")
response.code should equal(400)
And("The error message should indicate BANK_ID validation failed")
response.body.extract[ErrorMessage].message should include("BANK_ID")
}
}
}