Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

}
Expand Down
6 changes: 6 additions & 0 deletions obp-api/src/main/scala/code/api/util/ApiRole.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -409,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()
Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/util/ApiTag.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
|
Expand Down
139 changes: 138 additions & 1 deletion obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ 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
import code.bankconnectors.{Connector, LocalMappedConnectorInternal}
import code.bankconnectors.storedprocedure.StoredProcedureUtils
import code.bankconnectors.LocalMappedConnectorInternal._
import code.entitlement.Entitlement
import code.loginattempts.LoginAttempt
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -7084,6 +7150,77 @@ 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)
_ <- 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]
}
// 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))
}
}
}

}
}

Expand Down
15 changes: 15 additions & 0 deletions obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -319,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" )
Expand Down
Loading