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
3 changes: 2 additions & 1 deletion obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,8 @@ database_messages_scheduler_interval=3600
# Possibile values are: CONSUMER_CERTIFICATE, CONSUMER_KEY_VALUE, NONE
# consumer_validation_method_for_consent=CONSUMER_CERTIFICATE
#
# consents.max_time_to_live=3600
# Maximum allowed time_to_live (in seconds) for a consent. Default is 7776000 (90 days), matching PSD2 AIS / UK Open Banking.
# consents.max_time_to_live=7776000
# In case isn't defined default value is "true"
# consents.sca.enabled=true
# ---------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions obp-api/src/main/scala/code/api/constant/constant.scala
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ object Constant extends MdcLoggable {
def RATE_LIMIT_ACTIVE_PREFIX: String = getVersionedCachePrefix(RL_ACTIVE_NAMESPACE)
final val RATE_LIMIT_ACTIVE_CACHE_TTL: Int = APIUtil.getPropsValue("rateLimitActive.cache.ttl.seconds", "3600").toInt

// Default max time_to_live for consents, in seconds. 90 days — aligns with PSD2 AIS / UK Open Banking.
// Used as the fallback when the `consents.max_time_to_live` prop is unset.
final val DEFAULT_CONSENT_TTL: Int = 7776000

// Connector Cache Prefixes (with global namespace and versioning)
def CONNECTOR_PREFIX: String = getVersionedCachePrefix(CONNECTOR_NAMESPACE)

Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/api/util/ExampleValue.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1630,8 +1630,8 @@ object ExampleValue {
lazy val directDebitIdExample = ConnectorField(NoExampleProvided,NoDescriptionProvided)
glossaryItems += makeGlossaryItem("direct_debit_id", directDebitIdExample)

lazy val consentReferenceIdExample = ConnectorField("123456" ,NoDescriptionProvided)
glossaryItems += makeGlossaryItem("consent_id", consentReferenceIdExample)
lazy val consentReferenceIdExample = ConnectorField("fd13b9af-4f74-4d52-a7f1-7c2c12f3aa11" ,NoDescriptionProvided)
glossaryItems += makeGlossaryItem("consent_reference_id", consentReferenceIdExample)

lazy val consentIdExample = ConnectorField("9d429899-24f5-42c8-8565-943ffa6a7947",NoDescriptionProvided)
glossaryItems += makeGlossaryItem("consent_id", consentIdExample)
Expand Down
2 changes: 1 addition & 1 deletion obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5672,7 +5672,7 @@ object Glossary extends MdcLoggable {
|For onward calls to OBP-API, `OBP_AUTHORIZATION_VIA` selects:
|
|- **`oauth`** — pulls the access token from the MCP request context and sends `Authorization: Bearer ...`.
|- **`consent`** — if the endpoint declares any required roles and no `Consent-JWT` is supplied, the tool returns a `consent_required` payload listing the required roles and bank scope, so the client can elicit user approval and come back with a `Consent-JWT` header. Public / no-role endpoints skip this and call straight through.
|- **`consent`** — the default mode for user-facing deployments. `call_obp_api` requires a `Consent-JWT` for **every** endpoint except a small allowlist of genuinely public ones (`GET /root`, the bank directory `/banks` and `/banks/{BANK_ID}`, glossary, resource-docs, API metadata). For any other endpoint called without a `Consent-JWT`, the tool returns a `consent_required` payload required roles, bank / account / view scope, and `requires_view_access` / `is_user_scoped` flags — so the client can build the right consent and retry with a `Consent-JWT` header. Consent is required **by default**, not only for role-gated endpoints, because many identity-bound endpoints (`/users/current`, `/my/*`, account-access-via-view endpoints) declare no roles yet still need the caller's identity — a role-only gate would call them unauthenticated. The allowlist is deliberately conservative: a wrongly-excluded endpoint costs only an extra prompt, whereas wrongly skipping consent fails silently.
|- **`none`** — calls OBP unauthenticated (only useful for genuinely public endpoints).
|
|This means the consent flow is enforced at the MCP layer, not just at OBP-API: an agent cannot accidentally call a privileged endpoint without explicit user consent.
Expand Down
13 changes: 13 additions & 0 deletions obp-api/src/main/scala/code/api/util/migration/Migration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ object Migration extends MdcLoggable {
updateAccountAccessWithViewsViewUnionAll(startedBeforeSchemifier)
migrateChatRoomIsOpenRoom()
migrateChatRoomCreatedByAndLastMessageSender()
migrateConsentReferenceIdToUuid(startedBeforeSchemifier)
}

private def dummyScript(): Boolean = {
Expand Down Expand Up @@ -636,6 +637,18 @@ object Migration extends MdcLoggable {
}
}

private def migrateConsentReferenceIdToUuid(startedBeforeSchemifier: Boolean): Boolean = {
if(startedBeforeSchemifier == true) {
logger.warn(s"Migration.database.migrateConsentReferenceIdToUuid(true) cannot be run before Schemifier.")
true
} else {
val name = nameOf(migrateConsentReferenceIdToUuid(startedBeforeSchemifier))
runOnce(name) {
MigrationOfConsentReferenceIdUuid.migrate(name)
}
}
}

private def addAccountAccessWithViewsView(startedBeforeSchemifier: Boolean): Boolean = {
if(startedBeforeSchemifier == true) {
logger.warn(s"Migration.database.addAccountAccessWithViewsView(true) cannot be run before Schemifier.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package code.api.util.migration

import code.api.util.APIUtil
import code.api.util.migration.Migration.{DbFunction, saveLog}
import code.consent.MappedConsent
import net.liftweb.mapper.Schemifier

/**
* Migration: switch consent_reference_id from a stringified row PK (Long) to a UUID.
*
* On EXISTING deploys, this:
* 1. Backs up `mappedconsent` and `consent_item` (snapshot tables prefixed `backup_2026_05_`).
* 2. Drops the v_consent view (its column types are about to change).
* 3. Adds `mappedconsent.consent_reference_id VARCHAR(36)` (nullable for now).
* 4. Backfills every existing consent row with a fresh UUID.
* 5. Adds NOT NULL + UNIQUE on the new column.
* 6. Widens `consent_item.consent_reference_id` from BIGINT to VARCHAR(36).
* 7. Rewrites every consent_item row's consent_reference_id to point at its parent
* consent's new UUID (joining on the old Long via ::text cast).
*
* v_consent gets recreated by `MigrationOfConsentView.addConsentView` (already in the registry
* with this migration's updated projection), wrapped here so it's part of the same logical step.
*
* On FRESH deploys, Schemifier creates everything with the correct shape directly from the
* Scala model. The `tableExists` guards make most steps no-ops; backup CREATEs against an
* empty table produce empty backup tables, which is harmless.
*/
object MigrationOfConsentReferenceIdUuid {

def migrate(name: String): Boolean = {

Check failure on line 30 in obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ5UtSYfOZg2hyVeh5gY&open=AZ5UtSYfOZg2hyVeh5gY&pullRequest=2808
val dbDriver = APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver"
val isH2 = dbDriver.contains("org.h2.Driver")
// H2 is only used in tests. Schemifier builds a fresh schema each run with the new
// column types already in place (MappedConsent.mConsentReferenceId via MappedUUID,
// ConsentItem.consentReferenceId via MappedString(36)), and v_consent is recreated by
// addConsentView earlier in the migration chain. Running the Postgres/MSSQL ALTERs
// here would only break the fresh schema — gen_random_uuid()/USING-cast/UPDATE-FROM
// are not H2 syntax, and a failure mid-way drops v_consent without recreating it.
if (isH2) {
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
val endDate = System.currentTimeMillis()
saveLog(name, commitId, true, startDate, endDate, "H2 detected — fresh schema already has the new column shape; nothing to migrate.")
return true
}
DbFunction.tableExists(MappedConsent) match {
case true =>
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
val isMssql = dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver")
var isSuccessful = false
val sqlLog = new StringBuilder()

try {
// 1. Backups — table-snapshot copies so we can recover from any later failure.
val backupMappedConsent = if (isMssql) {
"SELECT * INTO backup_2026_05_mappedconsent FROM mappedconsent;"
} else {
"CREATE TABLE backup_2026_05_mappedconsent AS SELECT * FROM mappedconsent;"
}
val backupConsentItem = if (isMssql) {
"SELECT * INTO backup_2026_05_consent_item FROM consent_item;"
} else {
"CREATE TABLE backup_2026_05_consent_item AS SELECT * FROM consent_item;"
}
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backupMappedConsent)).append("\n")
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backupConsentItem)).append("\n")

// 2. Drop the existing v_consent view; its consent_reference_id column type is changing.
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => "DROP VIEW IF EXISTS v_consent;")).append("\n")

// 3. Add the new UUID column on mappedconsent, nullable for the backfill.
val addColumn = if (isMssql) {
"ALTER TABLE mappedconsent ADD consent_reference_id VARCHAR(36) NULL;"
} else {
"ALTER TABLE mappedconsent ADD COLUMN IF NOT EXISTS consent_reference_id VARCHAR(36);"
}
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumn)).append("\n")

// 4. Backfill every existing consent row with a fresh UUID.
val backfillConsents = if (isMssql) {
"UPDATE mappedconsent SET consent_reference_id = LOWER(CONVERT(varchar(36), NEWID())) WHERE consent_reference_id IS NULL;"
} else {
// gen_random_uuid() requires pgcrypto on older Postgres; built-in on PG13+.
"UPDATE mappedconsent SET consent_reference_id = gen_random_uuid()::text WHERE consent_reference_id IS NULL;"
}
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backfillConsents)).append("\n")

// 5. Enforce NOT NULL + UNIQUE.
val setNotNull = if (isMssql) {
"ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id VARCHAR(36) NOT NULL;"
} else {
"ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id SET NOT NULL;"
}
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => setNotNull)).append("\n")
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() =>
"ALTER TABLE mappedconsent ADD CONSTRAINT uq_mappedconsent_consent_reference_id UNIQUE (consent_reference_id);"
)).append("\n")

// 6. Widen consent_item.consent_reference_id from BIGINT to VARCHAR(36).
val alterConsentItem = if (isMssql) {
"ALTER TABLE consent_item ALTER COLUMN consent_reference_id VARCHAR(36);"
} else {
"ALTER TABLE consent_item ALTER COLUMN consent_reference_id TYPE VARCHAR(36) USING consent_reference_id::text;"
}
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => alterConsentItem)).append("\n")

// 7. Rewrite each consent_item row to point at its parent consent's new UUID.
// Old value is the parent's row PK (id) stringified; join on that, write the UUID.
val backfillConsentItem = if (isMssql) {
"""UPDATE ci SET ci.consent_reference_id = c.consent_reference_id
|FROM consent_item ci
|JOIN mappedconsent c ON ci.consent_reference_id = CAST(c.id AS VARCHAR(36));""".stripMargin
} else {
"""UPDATE consent_item ci
|SET consent_reference_id = c.consent_reference_id
|FROM mappedconsent c
|WHERE ci.consent_reference_id = c.id::text;""".stripMargin
}
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backfillConsentItem)).append("\n")

// 8. Recreate v_consent with the new column projection.
MigrationOfConsentView.addConsentView(name + "_view_rebuild")

isSuccessful = true
} catch {
case e: Exception =>
isSuccessful = false
sqlLog.append(s"\nException: ${e.getMessage}\n")
}

val endDate = System.currentTimeMillis()
val comment: String =
s"""Executed SQL:
|$sqlLog
|""".stripMargin
saveLog(name, commitId, isSuccessful, startDate, endDate, comment)
isSuccessful

case false =>
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
val isSuccessful = false
val endDate = System.currentTimeMillis()
val comment: String = s"""${MappedConsent._dbTableNameLC} table does not exist""".stripMargin
saveLog(name, commitId, isSuccessful, startDate, endDate, comment)
isSuccessful
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ object MigrationOfConsentView {
"""
|CREATE OR ALTER VIEW v_consent AS
|SELECT
| id AS consent_reference_id,
| consent_reference_id AS consent_reference_id,
| mconsentid AS consent_id,
| muserid AS created_by_user_id,
| mconsumerid AS consumer_id,
Expand All @@ -46,7 +46,7 @@ object MigrationOfConsentView {
"""
|CREATE OR REPLACE VIEW v_consent AS
|SELECT
| id AS consent_reference_id,
| consent_reference_id AS consent_reference_id,
| mconsentid AS consent_id,
| muserid AS created_by_user_id,
| mconsumerid AS consumer_id,
Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ object Http4s310 {
|Consumer-Key: ejznk505d132ryomnhbx1qmtohurbsbb0kijajsk
|cache-control: no-cache
|
|Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 3600 seconds.
|Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 7776000 seconds (90 days).
|
|Example of POST JSON:
|{
Expand Down Expand Up @@ -4473,7 +4473,7 @@ object Http4s310 {
StrongCustomerAuthentication.EMAIL.toString,
StrongCustomerAuthentication.IMPLICIT.toString).contains(scaMethod)
}
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600)
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL)
_ <- code.util.Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = Some(cc)) {
consentJson.time_to_live match {
case Some(ttl) => ttl <= maxTimeToLive
Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ object Http4s500 {
consentJson <- NewStyle.function.tryons(failMsg, 400, callContextOpt) {
net.liftweb.json.parse(rawBody).extract[PostConsentRequestJsonV500]
}
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600)
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = DEFAULT_CONSENT_TTL)
_ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) {
consentJson.time_to_live match {
case Some(ttl) => ttl <= maxTimeToLive
Expand Down Expand Up @@ -1210,7 +1210,7 @@ object Http4s500 {
} else {
Future.successful((BankId(""), AccountId(""), ViewId(""), CounterpartyId(""))): Future[(BankId, AccountId, ViewId, CounterpartyId)]
}
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600)
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = DEFAULT_CONSENT_TTL)
_ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) {
consentRequestJson.time_to_live match {
case Some(ttl) => ttl <= maxTimeToLive
Expand Down
6 changes: 3 additions & 3 deletions obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4676,7 +4676,7 @@ object Http4s510 {
|Consumer-Key: ejznk505d132ryomnhbx1qmtohurbsbb0kijajsk
|cache-control: no-cache
|
|Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 3600 seconds.
|Maximum time to live of the token is specified over props value consents.max_time_to_live. In case isn't defined default value is 7776000 seconds (90 days).
|
|Example of POST JSON:
|{
Expand Down Expand Up @@ -4768,7 +4768,7 @@ object Http4s510 {
consentJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostConsentBodyCommonJson ", 400, callContextOpt) {
net.liftweb.json.parse(cc.httpBody.getOrElse("")).extract[PostConsentBodyCommonJson]
}
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600)
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL)
_ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) {
consentJson.time_to_live match {
case Some(ttl) => ttl <= maxTimeToLive
Expand Down Expand Up @@ -4940,7 +4940,7 @@ object Http4s510 {
postConsentRequestJsonV510 <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostVRPConsentRequestJsonV510 ", 400, callContextOpt) {
parsedBody.extract[PostVRPConsentRequestJsonV510]
}
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = 3600)
maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty = "consents.max_time_to_live", defaultValue = Constant.DEFAULT_CONSENT_TTL)
_ <- Helper.booleanToFuture(s"$ConsentMaxTTL ($maxTimeToLive)", cc = callContextOpt) {
postConsentRequestJsonV510.time_to_live match {
case Some(ttl) => ttl <= maxTimeToLive
Expand Down
2 changes: 1 addition & 1 deletion obp-api/src/main/scala/code/consent/ConsentItem.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ConsentItem extends LongKeyedMapper[ConsentItem] with IdPK {
object consentItemId extends MappedUUID(this) {
override def dbColumnName = "consent_item_id"
}
object consentReferenceId extends MappedLong(this) {
object consentReferenceId extends MappedString(this, 36) {
override def dbColumnName = "consent_reference_id"
}
object itemType extends MappedString(this, 64) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object DoobieConsentQueries {
* Fields align with ConsentInfoJsonV510.
*/
case class ConsentRow(
consentReferenceId: Long,
consentReferenceId: String,
consentId: String,
createdByUserId: String,
consumerId: Option[String],
Expand Down Expand Up @@ -179,7 +179,7 @@ object DoobieConsentQueries {
* Insert consent items from the consent's views and entitlements.
* Called at consent creation time after the JWT is set.
*/
def insertConsentItems(consentReferenceId: Long, consentJWT: code.api.util.ConsentJWT): Unit = {
def insertConsentItems(consentReferenceId: String, consentJWT: code.api.util.ConsentJWT): Unit = {
val viewInserts = consentJWT.views.filter(_.bank_id.nonEmpty).map { view =>
val consentItemId = java.util.UUID.randomUUID().toString
val itemType = "VIEW"
Expand Down
Loading
Loading