From e3ae001ac5eec18cd753c89628c09dbd1e5b27cd Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 28 Jan 2026 17:00:00 +0100 Subject: [PATCH 1/6] Added POST /users/verify-credentials --- .../main/scala/code/api/util/ApiRole.scala | 3 + .../scala/code/api/v6_0_0/APIMethods600.scala | 72 ++++++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 6 ++ 3 files changed, 80 insertions(+), 1 deletion(-) 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 2140d58b60..94225d7ab2 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -200,6 +200,9 @@ object ApiRole extends MdcLoggable{ case class CanGetAnyUser (requiresBankId: Boolean = false) extends ApiRole lazy val canGetAnyUser = CanGetAnyUser() + case class CanVerifyUserCredentials(requiresBankId: Boolean = false) extends ApiRole + lazy val canVerifyUserCredentials = CanVerifyUserCredentials() + case class CanCreateAnyTransactionRequest(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateAnyTransactionRequest = CanCreateAnyTransactionRequest() 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 1ffe22eefd..ae57d904a4 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 @@ -30,7 +30,7 @@ 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, RedisCacheStatusJsonV600, RelatedLinkJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600} +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.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics @@ -7084,6 +7084,76 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + verifyUserCredentials, + implementedInApiVersion, + nameOf(verifyUserCredentials), + "POST", + "/users/verify-credentials", + "Verify User Credentials", + s"""Verify a user's credentials (username, password, provider) and return user information if valid. + | + |This endpoint validates the provided credentials without creating a token or session. + |It can be used to verify user credentials in external systems. + | + |${userAuthenticationMessage(true)} + | + |""", + PostVerifyUserCredentialsJsonV600( + username = "username", + password = "password", + provider = Constant.localIdentityProvider + ), + userJsonV200, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + InvalidLoginCredentials, + UsernameHasBeenLocked, + UnknownError + ), + List(apiTagUser), + Some(List(canVerifyUserCredentials)) + ) + + lazy val verifyUserCredentials: OBPEndpoint = { + case "users" :: "verify-credentials" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canVerifyUserCredentials, callContext) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, callContext) { + json.extract[PostVerifyUserCredentialsJsonV600] + } + // Validate credentials using the existing AuthUser mechanism + resourceUserIdBox = code.model.dataAccess.AuthUser.getResourceUserId(postedData.username, postedData.password) + // Check if account is locked + _ <- Helper.booleanToFuture(UsernameHasBeenLocked, 401, callContext) { + resourceUserIdBox != Full(code.model.dataAccess.AuthUser.usernameLockedStateCode) + } + // Check if credentials are valid + resourceUserId <- Future { + resourceUserIdBox + } map { + x => unboxFullOrFail(x, callContext, InvalidLoginCredentials, 401) + } + // Get the user object + user <- Future { + Users.users.vend.getUserByResourceUserId(resourceUserId) + } map { + x => unboxFullOrFail(x, callContext, InvalidLoginCredentials, 401) + } + // Verify provider matches if specified and not empty + _ <- Helper.booleanToFuture(InvalidLoginCredentials, 401, callContext) { + postedData.provider.isEmpty || user.provider == postedData.provider + } + } yield { + (JSONFactory200.createUserJSON(user), 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 97ac5265d9..863fa46513 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 @@ -206,6 +206,12 @@ case class CreateUserJsonV600( validating_application: Option[String] = None ) +case class PostVerifyUserCredentialsJsonV600( + username: String, + password: String, + provider: String +) + case class MigrationScriptLogJsonV600( migration_script_log_id: String, name: String, From 90dcd76d5faae2cea76997c0bd2c69edbed5b2f0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 28 Jan 2026 17:12:01 +0100 Subject: [PATCH 2/6] Tests for Verify User Credentials --- .../v6_0_0/VerifyUserCredentialsTest.scala | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala new file mode 100644 index 0000000000..852702fc62 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala @@ -0,0 +1,221 @@ +package code.api.v6_0_0 + +import code.api.Constant +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanVerifyUserCredentials +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.{InvalidLoginCredentials, UserHasMissingRoles, UsernameHasBeenLocked} +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.loginattempts.LoginAttempt +import code.model.dataAccess.AuthUser +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.mapper.By +import net.liftweb.util.Helpers.randomString +import org.scalatest.Tag + +/** + * Test suite for Verify User Credentials endpoint (POST /obp/v6.0.0/users/verify-credentials) + * + * Tests cover: + * - Anonymous access (should fail with 401) + * - Missing role (should fail with 403) + * - Successful credential verification + * - Invalid password (should fail with 401) + * - Invalid username (should fail with 401) + * - Account locked after too many failed attempts + * - Provider mismatch + */ +class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.verifyUserCredentials)) + + // Test data + val testUsername = "verify_creds_test_" + randomString(8).toLowerCase + val testPassword = "TestPassword123!" + val testEmail = testUsername + "@example.com" + var testAuthUser: AuthUser = null + + override def beforeAll(): Unit = { + super.beforeAll() + // Create a test user for credential verification + testAuthUser = AuthUser.create + .email(testEmail) + .username(testUsername) + .password(testPassword) + .validated(true) + .firstName("Test") + .lastName("User") + .provider(Constant.localIdentityProvider) + .saveMe() + } + + override def afterAll(): Unit = { + // Clean up test user + if (testAuthUser != null) { + testAuthUser.delete_! + } + // Reset any login attempt locks + LoginAttempt.resetBadLoginAttempts(Constant.localIdentityProvider, testUsername) + super.afterAll() + } + + feature(s"Verify User Credentials - POST /obp/v6.0.0/users/verify-credentials - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val postJson = Map( + "username" -> testUsername, + "password" -> testPassword, + "provider" -> Constant.localIdentityProvider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST + val response = makePostRequest(request, write(postJson)) + + 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) + } + + scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { + When("We make the request as an authenticated user without the required role") + val postJson = Map( + "username" -> testUsername, + "password" -> testPassword, + "provider" -> Constant.localIdentityProvider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 403") + response.code should equal(403) + And("The error message should indicate missing role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanVerifyUserCredentials) + } + + scenario("Successfully verify valid credentials", ApiEndpoint, VersionOfApi) { + Given("User has the required entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) + + When("We verify valid credentials") + val postJson = Map( + "username" -> testUsername, + "password" -> testPassword, + "provider" -> Constant.localIdentityProvider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 200") + response.code should equal(200) + + And("The response should contain user details") + val json = response.body + (json \ "username").extract[String] should equal(testUsername) + (json \ "email").extract[String] should equal(testEmail) + (json \ "provider").extract[String] should equal(Constant.localIdentityProvider) + (json \ "user_id").extract[String] should not be empty + } + + scenario("Fail to verify with wrong password", ApiEndpoint, VersionOfApi) { + Given("User has the required entitlement") + // Entitlement already added in previous scenario + + When("We verify credentials with wrong password") + val postJson = Map( + "username" -> testUsername, + "password" -> "WrongPassword123!", + "provider" -> Constant.localIdentityProvider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 401") + response.code should equal(401) + And("The error message should indicate invalid credentials") + response.body.extract[ErrorMessage].message should include("OBP-20004") + + // Reset bad login attempts for this user + LoginAttempt.resetBadLoginAttempts(Constant.localIdentityProvider, testUsername) + } + + scenario("Fail to verify with non-existent username", ApiEndpoint, VersionOfApi) { + Given("User has the required entitlement") + // Entitlement already added + + When("We verify credentials with non-existent username") + val postJson = Map( + "username" -> ("nonexistent_user_" + randomString(8)), + "password" -> testPassword, + "provider" -> Constant.localIdentityProvider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 401") + response.code should equal(401) + And("The error message should indicate invalid credentials") + response.body.extract[ErrorMessage].message should include("OBP-20004") + } + + scenario("Fail to verify with empty provider (should still work - provider check is optional)", ApiEndpoint, VersionOfApi) { + Given("User has the required entitlement") + // Entitlement already added + + When("We verify valid credentials with empty provider") + val postJson = Map( + "username" -> testUsername, + "password" -> testPassword, + "provider" -> "" + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 200 (provider check is skipped when empty)") + response.code should equal(200) + + And("The response should contain user details") + (response.body \ "username").extract[String] should equal(testUsername) + } + + scenario("Fail to verify with mismatched provider", ApiEndpoint, VersionOfApi) { + Given("User has the required entitlement") + // Entitlement already added + + When("We verify credentials with wrong provider") + val postJson = Map( + "username" -> testUsername, + "password" -> testPassword, + "provider" -> "some_other_provider" + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 401") + response.code should equal(401) + And("The error message should indicate invalid credentials") + response.body.extract[ErrorMessage].message should include("OBP-20004") + } + + scenario("Fail with invalid JSON format", ApiEndpoint, VersionOfApi) { + Given("User has the required entitlement") + // Entitlement already added + + When("We send invalid JSON") + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = makePostRequest(request, "{ invalid json }") + + Then("We should get a 400") + response.code should equal(400) + And("The error message should indicate invalid JSON format") + response.body.extract[ErrorMessage].message should include("OBP-10001") + } + + } +} From 5489dccc2c03a08a1836146a88213186f1a7ff74 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 28 Jan 2026 21:12:16 +0100 Subject: [PATCH 3/6] Fix Verify User Credential tests --- .../v6_0_0/VerifyUserCredentialsTest.scala | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala index 852702fc62..3d712b9244 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala @@ -13,8 +13,8 @@ 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.common.Full import net.liftweb.json.Serialization.write -import net.liftweb.mapper.By import net.liftweb.util.Helpers.randomString import org.scalatest.Tag @@ -27,8 +27,8 @@ import org.scalatest.Tag * - Successful credential verification * - Invalid password (should fail with 401) * - Invalid username (should fail with 401) - * - Account locked after too many failed attempts * - Provider mismatch + * - Invalid JSON format */ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { @@ -100,8 +100,8 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { } scenario("Successfully verify valid credentials", ApiEndpoint, VersionOfApi) { - Given("User has the required entitlement") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) When("We verify valid credentials") val postJson = Map( @@ -110,7 +110,12 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { "provider" -> Constant.localIdentityProvider ) val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) - val response = makePostRequest(request, write(postJson)) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } Then("We should get a 200") response.code should equal(200) @@ -124,8 +129,8 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { } scenario("Fail to verify with wrong password", ApiEndpoint, VersionOfApi) { - Given("User has the required entitlement") - // Entitlement already added in previous scenario + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) When("We verify credentials with wrong password") val postJson = Map( @@ -134,20 +139,24 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { "provider" -> Constant.localIdentityProvider ) val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) - val response = makePostRequest(request, write(postJson)) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Reset bad login attempts for this user + LoginAttempt.resetBadLoginAttempts(Constant.localIdentityProvider, testUsername) + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } Then("We should get a 401") response.code should equal(401) And("The error message should indicate invalid credentials") response.body.extract[ErrorMessage].message should include("OBP-20004") - - // Reset bad login attempts for this user - LoginAttempt.resetBadLoginAttempts(Constant.localIdentityProvider, testUsername) } scenario("Fail to verify with non-existent username", ApiEndpoint, VersionOfApi) { - Given("User has the required entitlement") - // Entitlement already added + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) When("We verify credentials with non-existent username") val postJson = Map( @@ -156,7 +165,12 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { "provider" -> Constant.localIdentityProvider ) val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) - val response = makePostRequest(request, write(postJson)) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } Then("We should get a 401") response.code should equal(401) @@ -164,9 +178,9 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { response.body.extract[ErrorMessage].message should include("OBP-20004") } - scenario("Fail to verify with empty provider (should still work - provider check is optional)", ApiEndpoint, VersionOfApi) { - Given("User has the required entitlement") - // Entitlement already added + scenario("Successfully verify with empty provider (provider check is optional)", ApiEndpoint, VersionOfApi) { + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) When("We verify valid credentials with empty provider") val postJson = Map( @@ -175,7 +189,12 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { "provider" -> "" ) val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) - val response = makePostRequest(request, write(postJson)) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } Then("We should get a 200 (provider check is skipped when empty)") response.code should equal(200) @@ -185,8 +204,8 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { } scenario("Fail to verify with mismatched provider", ApiEndpoint, VersionOfApi) { - Given("User has the required entitlement") - // Entitlement already added + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) When("We verify credentials with wrong provider") val postJson = Map( @@ -195,7 +214,12 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { "provider" -> "some_other_provider" ) val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) - val response = makePostRequest(request, write(postJson)) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } Then("We should get a 401") response.code should equal(401) @@ -204,12 +228,17 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { } scenario("Fail with invalid JSON format", ApiEndpoint, VersionOfApi) { - Given("User has the required entitlement") - // Entitlement already added + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) When("We send invalid JSON") val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) - val response = makePostRequest(request, "{ invalid json }") + val response = try { + makePostRequest(request, "{ invalid json }") + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } Then("We should get a 400") response.code should equal(400) From af36601d76f7cf88625a347bb4097e201562bc4e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 28 Jan 2026 21:40:23 +0100 Subject: [PATCH 4/6] Allow CanVerifyUserCredentials for isSuperAdmin --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ae57d904a4..ebe863ef65 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 @@ -7122,7 +7122,8 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", u.userId, canVerifyUserCredentials, callContext) + _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) + else NewStyle.function.hasEntitlement("", u.userId, canVerifyUserCredentials, callContext) postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, callContext) { json.extract[PostVerifyUserCredentialsJsonV600] } From 2731a4954b5037542c666accfdfb2e386a424e1a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 28 Jan 2026 21:46:49 +0100 Subject: [PATCH 5/6] show warning at boot for Super admin users. --- .../main/scala/bootstrap/liftweb/Boot.scala | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index ca8eceb4dc..1fdb2eb0fc 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -328,6 +328,8 @@ class Boot extends MdcLoggable { createBootstrapSuperUser() + warnAboutSuperAdminUsers() + //launch the scheduler to clean the database from the expired tokens and nonces, 1 hour DataBaseCleanerScheduler.start(intervalInSeconds = 60*60) @@ -1026,6 +1028,33 @@ class Boot extends MdcLoggable { } + /** + * Warn about Super Admin Users + * Super admin is intended for bootstrapping only. Users should grant themselves + * proper roles (e.g. CanCreateEntitlementAtAnyBank) and then remove their user_id + * from the super_admin_user_ids props setting. + */ + private def warnAboutSuperAdminUsers(): Unit = { + APIUtil.getPropsValue("super_admin_user_ids") match { + case Full(v) if v.trim.nonEmpty => + val userIds = v.split(",").map(_.trim).filter(_.nonEmpty).toList + if (userIds.nonEmpty) { + logger.warn("========================================================================") + logger.warn("WARNING: super_admin_user_ids is configured with the following user IDs:") + userIds.foreach(userId => logger.warn(s" - $userId")) + logger.warn("") + logger.warn("Super admin is intended for BOOTSTRAPPING ONLY.") + logger.warn("These users bypass normal role checks.") + logger.warn("Please:") + logger.warn(" 1. Login as a super admin user") + logger.warn(" 2. Grant yourself CanCreateEntitlementAtAnyBank (and other required roles)") + logger.warn(" 3. Remove your user_id from super_admin_user_ids in props") + logger.warn("========================================================================") + } + case _ => // No super admin users configured, nothing to warn about + } + } + LiftRules.statelessDispatch.append(aliveCheck) } From dc53c9367bc254922ecce188d5495de8a5f1afae Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 29 Jan 2026 05:03:39 +0100 Subject: [PATCH 6/6] Adding GET /system/connectors/stored_procedure_vDec2019/health --- .../main/scala/code/api/util/ApiRole.scala | 3 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/v3_1_0/APIMethods310.scala | 2 +- .../scala/code/api/v6_0_0/APIMethods600.scala | 66 +++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 9 ++ .../StoredProcedureUtils.scala | 106 ++++++++++++++++++ 6 files changed, 186 insertions(+), 1 deletion(-) 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 94225d7ab2..2500954cbf 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -412,6 +412,9 @@ object ApiRole extends MdcLoggable{ case class CanGetDatabasePoolInfo(requiresBankId: Boolean = false) extends ApiRole lazy val canGetDatabasePoolInfo = CanGetDatabasePoolInfo() + case class CanGetConnectorHealth(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetConnectorHealth = CanGetConnectorHealth() + case class CanGetCacheNamespaces(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCacheNamespaces = CanGetCacheNamespaces() 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 a3554e474c..2f2e43c250 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -113,6 +113,7 @@ object ApiTag { val apiTagJsonSchemaValidation = ResourceDocTag("JSON-Schema-Validation") val apiTagAuthenticationTypeValidation = ResourceDocTag("Authentication-Type-Validation") val apiTagConnectorMethod = ResourceDocTag("Connector-Method") + val apiTagConnector = ResourceDocTag("Connector") // To mark the Berlin Group APIs suggested order of implementation val apiTagBerlinGroupM = ResourceDocTag("Berlin-Group-M") diff --git a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala index 719e82a049..e65e1079a9 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala @@ -1854,7 +1854,7 @@ trait APIMethods310 { "GET", "/connector/loopback", "Get Connector Status (Loopback)", - s"""This endpoint makes a call to the Connector to check the backend transport is reachable. (WIP) + s"""This endpoint makes a call to the Connector to check the backend transport is reachable. (Deprecated) | |${userAuthenticationMessage(true)} | 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 ebe863ef65..7b88948f37 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,6 +35,7 @@ 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.entitlement.Entitlement import code.loginattempts.LoginAttempt @@ -851,6 +852,71 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getStoredProcedureConnectorHealth, + implementedInApiVersion, + nameOf(getStoredProcedureConnectorHealth), + "GET", + "/system/connectors/stored_procedure_vDec2019/health", + "Get Stored Procedure Connector Health", + """Returns health status of the stored procedure connector including: + | + |- Connection status (ok/error) + |- Database server name: identifies which backend node handled the request (useful for load balancer diagnostics) + |- Server IP address + |- Database name + |- Response time in milliseconds + |- Error message (if any) + | + |Supports database-specific queries for: SQL Server, PostgreSQL, Oracle, and MySQL/MariaDB. + | + |This endpoint is useful for diagnosing connectivity issues, especially when the database is behind a load balancer + |and you need to identify which node is responding or experiencing SSL certificate issues. + | + |Note: This endpoint may take a long time to respond if the database connection is slow or experiencing issues. + |The response time depends on the connection pool timeout and JDBC driver settings. + | + |Authentication is Required + |""", + EmptyBody, + StoredProcedureConnectorHealthJsonV600( + status = "ok", + server_name = Some("DBSERVER01"), + server_ip = Some("10.0.1.50"), + database_name = Some("obp_adapter"), + response_time_ms = 45, + error_message = None + ), + List( + AuthenticatedUserIsRequired, + UserHasMissingRoles, + UnknownError + ), + List(apiTagConnector, apiTagSystem, apiTagApi), + Some(List(canGetConnectorHealth)) + ) + + lazy val getStoredProcedureConnectorHealth: OBPEndpoint = { + case "system" :: "connectors" :: "stored_procedure_vDec2019" :: "health" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canGetConnectorHealth, callContext) + } yield { + val health = StoredProcedureUtils.getHealth() + val result = StoredProcedureConnectorHealthJsonV600( + status = health.status, + server_name = health.serverName, + server_ip = health.serverIp, + database_name = health.databaseName, + response_time_ms = health.responseTimeMs, + error_message = health.errorMessage + ) + (result, HttpCode.`200`(callContext)) + } + } + } + lazy val getCurrentConsumer: OBPEndpoint = { case "consumers" :: "current" :: Nil JsonGet _ => { cc => { 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 863fa46513..015873293e 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 @@ -325,6 +325,15 @@ case class DatabasePoolInfoJsonV600( keepalive_time_ms: Long ) +case class StoredProcedureConnectorHealthJsonV600( + status: String, + server_name: Option[String], + server_ip: Option[String], + database_name: Option[String], + response_time_ms: Long, + error_message: Option[String] +) + case class PostCustomerJsonV600( legal_name: String, customer_number: Option[String] = None, diff --git a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala index 12aa0b4d79..c0cd3cd4e4 100644 --- a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala +++ b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureUtils.scala @@ -49,6 +49,112 @@ object StoredProcedureUtils extends MdcLoggable{ } + /** + * Health check case class for stored procedure connector + */ + case class StoredProcedureConnectorHealth( + status: String, + serverName: Option[String], + serverIp: Option[String], + databaseName: Option[String], + responseTimeMs: Long, + errorMessage: Option[String] + ) + + /** + * Perform a health check on the stored procedure connector. + * Executes a database-specific query to verify connectivity and identify the backend node. + * Supports: SQL Server, PostgreSQL, Oracle, and MySQL. + */ + def getHealth(): StoredProcedureConnectorHealth = { + val startTime = System.currentTimeMillis() + try { + val (serverName, serverIp, databaseName) = scalikeDB readOnly { implicit session => + val driver = APIUtil.getPropsValue("stored_procedure_connector.driver", "") + + if (driver.contains("sqlserver")) { + // Microsoft SQL Server + val result = sql""" + SELECT + @@SERVERNAME AS server_name, + CAST(CONNECTIONPROPERTY('local_net_address') AS VARCHAR(50)) AS server_ip, + DB_NAME() AS database_name + """.map(rs => ( + Option(rs.string("server_name")), + Option(rs.string("server_ip")), + Option(rs.string("database_name")) + )).single.apply() + result.getOrElse((None, None, None)) + } else if (driver.contains("postgresql")) { + // PostgreSQL + val result = sql""" + SELECT + inet_server_addr()::text AS server_ip, + current_database() AS database_name, + (SELECT setting FROM pg_settings WHERE name = 'cluster_name') AS server_name + """.map(rs => ( + rs.stringOpt("server_name"), + rs.stringOpt("server_ip"), + rs.stringOpt("database_name") + )).single.apply() + result.getOrElse((None, None, None)) + } else if (driver.contains("oracle")) { + // Oracle + val result = sql""" + SELECT + SYS_CONTEXT('USERENV', 'SERVER_HOST') AS server_name, + SYS_CONTEXT('USERENV', 'IP_ADDRESS') AS server_ip, + SYS_CONTEXT('USERENV', 'DB_NAME') AS database_name + FROM DUAL + """.map(rs => ( + Option(rs.string("server_name")), + Option(rs.string("server_ip")), + Option(rs.string("database_name")) + )).single.apply() + result.getOrElse((None, None, None)) + } else if (driver.contains("mysql") || driver.contains("mariadb")) { + // MySQL / MariaDB + val result = sql""" + SELECT + @@hostname AS server_name, + @@bind_address AS server_ip, + DATABASE() AS database_name + """.map(rs => ( + Option(rs.string("server_name")), + Option(rs.string("server_ip")), + Option(rs.string("database_name")) + )).single.apply() + result.getOrElse((None, None, None)) + } else { + // Generic fallback - just test connectivity + sql"SELECT 1".map(_ => ()).single.apply() + (None, None, None) + } + } + val responseTime = System.currentTimeMillis() - startTime + StoredProcedureConnectorHealth( + status = "ok", + serverName = serverName, + serverIp = serverIp, + databaseName = databaseName, + responseTimeMs = responseTime, + errorMessage = None + ) + } catch { + case e: Exception => + val responseTime = System.currentTimeMillis() - startTime + logger.error(s"Stored procedure connector health check failed: ${e.getMessage}", e) + StoredProcedureConnectorHealth( + status = "error", + serverName = None, + serverIp = None, + databaseName = None, + responseTimeMs = responseTime, + errorMessage = Some(e.getMessage) + ) + } + } + def callProcedure[T: Manifest](procedureName: String, outBound: TopicTrait): Box[T] = { val procedureParam: String = write(outBound) // convert OutBound to json string logger.debug(s"${StoredProcedureConnector_vDec2019.toString} outBoundJson: $procedureName = $procedureParam" )