diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 25a7b10509..b0d8cdba07 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -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 # --------------------------------------------------------- diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 9be5407d6b..db2143d8d0 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -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) diff --git a/obp-api/src/main/scala/code/api/util/ExampleValue.scala b/obp-api/src/main/scala/code/api/util/ExampleValue.scala index 898079fa68..a2b974e732 100644 --- a/obp-api/src/main/scala/code/api/util/ExampleValue.scala +++ b/obp-api/src/main/scala/code/api/util/ExampleValue.scala @@ -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) diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index ad6c29dd3e..80f26baea4 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -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. diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index f9bd3939cf..698a48edf8 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -118,6 +118,7 @@ object Migration extends MdcLoggable { updateAccountAccessWithViewsViewUnionAll(startedBeforeSchemifier) migrateChatRoomIsOpenRoom() migrateChatRoomCreatedByAndLastMessageSender() + migrateConsentReferenceIdToUuid(startedBeforeSchemifier) } private def dummyScript(): Boolean = { @@ -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.") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala new file mode 100644 index 0000000000..5a1a05e58f --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala @@ -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 = { + 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 + } + } +} diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala index 2bf404de61..fd78410e6f 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentView.scala @@ -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, @@ -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, diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index 066661aabb..05c5ba15e5 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -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: |{ @@ -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 diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 878c4d47b6..8a3fb98a64 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -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 @@ -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 diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 5807783cfb..ad3f8f6ef6 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -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: |{ @@ -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 @@ -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 diff --git a/obp-api/src/main/scala/code/consent/ConsentItem.scala b/obp-api/src/main/scala/code/consent/ConsentItem.scala index 66403d81df..d1252df093 100644 --- a/obp-api/src/main/scala/code/consent/ConsentItem.scala +++ b/obp-api/src/main/scala/code/consent/ConsentItem.scala @@ -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) { diff --git a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala index 0be3652481..7bfb7e413c 100644 --- a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala +++ b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala @@ -24,7 +24,7 @@ object DoobieConsentQueries { * Fields align with ConsentInfoJsonV510. */ case class ConsentRow( - consentReferenceId: Long, + consentReferenceId: String, consentId: String, createdByUserId: String, consumerId: Option[String], @@ -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" diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 34eae3c43f..da69ce585e 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -333,7 +333,7 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo result.foreach { savedConsent => try { consentJWTParsed.foreach { consentJWT => - DoobieConsentQueries.insertConsentItems(savedConsent.id.get, consentJWT) + DoobieConsentQueries.insertConsentItems(savedConsent.mConsentReferenceId.get, consentJWT) } } catch { case e: Exception => @@ -475,6 +475,11 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit object mJwtExpiresAt extends MappedDateTime(this) { override def dbColumnName = "jwt_expires_at" } + // Stable external identifier for the consent — referenced by consent_item.consent_reference_id + // and surfaced in v5.1.0 JSON. Replaces the historical derivation from the row PK. + object mConsentReferenceId extends MappedUUID(this) { + override def dbColumnName = "consent_reference_id" + } override def consentId: String = mConsentId.get override def userId: String = mUserId.get @@ -503,11 +508,11 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit override def transactionToDateTime= mTransactionToDateTime.get override def creationDateTime= createdAt.get override def statusUpdateDateTime= mStatusUpdateDateTime.get - override def consentReferenceId = id.get.toString + override def consentReferenceId = mConsentReferenceId.get override def note = mNote.get } object MappedConsent extends MappedConsent with LongKeyedMetaMapper[MappedConsent] { - override def dbIndexes = UniqueIndex(mConsentId) :: Index(mUserId) :: Index(mUserId, createdAt) :: super.dbIndexes + override def dbIndexes = UniqueIndex(mConsentId) :: UniqueIndex(mConsentReferenceId) :: Index(mUserId) :: Index(mUserId, createdAt) :: super.dbIndexes } diff --git a/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala index 952bda759e..03fd2b5f11 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/ConsentTest.scala @@ -85,7 +85,7 @@ class ConsentTest extends V310ServerSetup { .copy(valid_from = Some(new Date())) .copy(views=views) - val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=3600) + val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=Constant.DEFAULT_CONSENT_TTL) val timeToLive: Option[Long] = Some(maxTimeToLive + 10) feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala index 24ef66169a..a2c38d523e 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentObpTest.scala @@ -73,7 +73,7 @@ class ConsentObpTest extends V510ServerSetup { .copy(consumer_id=Some(testConsumer.consumerId.get)) .copy(views=views) - val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=3600) + val maxTimeToLive = APIUtil.getPropsAsIntValue(nameOfProperty="consents.max_time_to_live", defaultValue=Constant.DEFAULT_CONSENT_TTL) val timeToLive: Option[Long] = Some(maxTimeToLive + 10) feature(s"test $CreateConsent version $VersionOfApi - Unauthorized access") diff --git a/scripts/count_v7_endpoints.py b/scripts/count_v7_endpoints.py new file mode 100644 index 0000000000..6d361e1178 --- /dev/null +++ b/scripts/count_v7_endpoints.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +""" +Count the OBP endpoints reachable via /obp/v7.0.0/. + +Statically reproduces Http4s700.allResourceDocs. + +Inputs are derived from source — no hardcoded data tables: + * Version files are discovered by globbing obp-api/src/main/scala/code/api/v*/ + for Http4s{NNN}.scala. All registrations live in Http4s files now — the + older Lift APIMethods*.scala files have been emptied/commented out. + * excludeEndpoints lists are extracted from each version's OBPAPI{a}_{b}_{c}.scala + (or OBPAPI{a}.{b}.{c}.scala for v1.2.1) — and from Http4s700.scala for v7 + which has no OBPAPI counterpart. + +Aggregation chain (matches the runtime code in each OBPAPI{a}_{b}_{c}.scala): + OBPAPI1_2_1.allResourceDocs = Http4s121.resourceDocs (chain root) + OBPAPI{N}.allResourceDocs = collectResourceDocs(OBPAPI{N-1}, Http4s{N}) + .filterNot(excludeEndpoints if any) + Http4s700.allResourceDocs = collectResourceDocs(OBPAPI6_0_0, Http4s700) + .filterNot(v7 excludeEndpoints — currently Nil) + +collectResourceDocs: concat, stable sort by version descending, dedup by (url, verb). +filterNot: drop docs whose partialFunctionName is in the excluded-names set. + +Each run prints a self-check that flags any `\\w*resourceDocs += ResourceDoc` +line that wasn't parsed and isn't obviously commented out — i.e. a new buffer +name, a split-line registration, or a constructor-shape change that broke +parsing. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SRC = REPO / "obp-api" / "src" / "main" / "scala" / "code" / "api" + +VERSION_DIR_RE = re.compile(r"^v(\d+)_(\d+)_(\d+)$") +REG_START = re.compile(r"^\s*(?:static)?[Rr]esourceDocs\s*\+=\s*ResourceDoc\s*\(") +ANY_REG_REF = re.compile(r"\w*[Rr]esourceDocs\s*\+=\s*ResourceDoc\b") +NAMEOF_RE = re.compile(r"nameOf\s*\(\s*[\w.]*?(\w+)\s*\)") +EXCLUDE_DEF_RE = re.compile(r"(?:lazy\s+)?val\s+excludeEndpoints\b[^=]*=") + +HTTP_VERBS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + + +class Doc: + __slots__ = ("verb", "url", "func", "version", "src") + + def __init__(self, verb: str, url: str, func: str, version: tuple, src: str): + self.verb, self.url, self.func = verb, url, func + self.version, self.src = version, src + + def key(self): + return (self.url, self.verb) + + +# -- auto-discovery of version files ------------------------------------ + +def discover_version_files() -> list[tuple[tuple, Path]]: + """Find every v*/ directory and resolve its Http4s{NNN}.scala source file. + + Every version's contribution to the aggregation chain lives in its + Http4s{NNN}.scala (the older Lift APIMethods*.scala files have been + commented out and no longer carry registrations). + """ + found = [] + for entry in sorted(SRC.iterdir()): + if not entry.is_dir(): + continue + m = VERSION_DIR_RE.match(entry.name) + if not m: + continue + version = tuple(int(g) for g in m.groups()) + nnn = "".join(str(p) for p in version) + candidate = entry / f"Http4s{nnn}.scala" + if candidate.is_file(): + found.append((version, candidate)) + else: + print( + f"WARN: directory {entry.name} has no {candidate.name} — skipped", + file=sys.stderr, + ) + return found + + +def find_obpapi_file(version: tuple) -> Path | None: + """Locate the file that owns this version's excludeEndpoints list. + + Most versions: OBPAPI{a}_{b}_{c}.scala. v1.2.1 is an outlier + (OBPAPI1.2.1.scala with dots). v7 has no OBPAPI counterpart — its + excludeEndpoints lives in Http4s700.scala. + """ + a, b, c = version + vdir = SRC / f"v{a}_{b}_{c}" + for candidate in ( + vdir / f"OBPAPI{a}_{b}_{c}.scala", + vdir / f"OBPAPI{a}.{b}.{c}.scala", + vdir / f"Http4s{a}{b}{c}.scala", + ): + if candidate.is_file(): + return candidate + return None + + +# -- excludeEndpoints extraction --------------------------------------- + +def extract_excludes(path: Path) -> set[str]: + """Pull names from `(lazy )?val excludeEndpoints = nameOf(...) :: ... :: Nil`. + + Returns the empty set if the val is absent (e.g. v3.0.0, v5.0.0). + """ + lines = path.read_text(encoding="utf-8").splitlines() + start = next((i for i, line in enumerate(lines) if EXCLUDE_DEF_RE.search(line)), None) + if start is None: + return set() + + names: set[str] = set() + in_block_comment = False + for raw in lines[start:]: + line = raw + # close an open block comment + if in_block_comment: + end = line.find("*/") + if end == -1: + continue + line = line[end + 2:] + in_block_comment = False + # open block comment that doesn't close on this line + if "/*" in line and "*/" not in line[line.find("/*"):]: + line = line[: line.find("/*")] + in_block_comment = True + # line comment + if "//" in line: + line = line[: line.find("//")] + names.update(NAMEOF_RE.findall(line)) + if line.strip() in {"Nil", "Nil)"} or line.rstrip().endswith("Nil"): + break + return names + + +# -- registration parsing ---------------------------------------------- + +def split_top_level_args(text: str, want: int) -> list[str]: + """Return the first `want` comma-separated args of a Scala call. + + `text` begins just after the opening '(' of ResourceDoc(...). Tracks single- + and triple-quoted strings, nested brackets, // line comments and /* */ block + comments so commas inside any of those aren't treated as separators. + """ + args, buf = [], [] + depth = 0 + i, n = 0, len(text) + while i < n and len(args) < want: + c, two, three = text[i], text[i:i + 2], text[i:i + 3] + if three == '"""': + end = text.find('"""', i + 3) + if end == -1: + buf.append(text[i:]); break + buf.append(text[i:end + 3]); i = end + 3; continue + if c == '"': + j = i + 1 + while j < n: + if text[j] == "\\": + j += 2; continue + if text[j] == '"': + break + j += 1 + buf.append(text[i:j + 1]); i = j + 1; continue + if two == "//": + end = text.find("\n", i) + i = n if end == -1 else end + continue + if two == "/*": + end = text.find("*/", i + 2) + i = n if end == -1 else end + 2 + continue + if c in "([{": + depth += 1; buf.append(c); i += 1; continue + if c in ")]}": + if depth == 0: + break + depth -= 1; buf.append(c); i += 1; continue + if c == "," and depth == 0: + args.append("".join(buf)); buf = []; i += 1; continue + buf.append(c); i += 1 + if buf and len(args) < want: + args.append("".join(buf)) + return args + + +def strip_str_literal(token: str) -> str: + s = token.strip() + if s.startswith("s"): + s = s[1:].strip() + if s.startswith('"""') and s.endswith('"""'): + return s[3:-3] + if s.startswith('"') and s.endswith('"'): + return s[1:-1] + return s + + +def parse_file(path: Path, version: tuple) -> tuple[list[Doc], set[int]]: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + docs, parsed_lines = [], set() + for idx, line in enumerate(lines): + if not REG_START.match(line): + continue + parsed_lines.add(idx) + chunk = "".join(lines[idx:idx + 60]) + chunk = chunk[chunk.index("ResourceDoc") + len("ResourceDoc"):] + chunk = chunk[chunk.index("(") + 1:] + args = split_top_level_args(chunk, want=5) + if len(args) < 5: + print(f" WARN: incomplete args at {path.name}:{idx + 1}", file=sys.stderr) + continue + m = NAMEOF_RE.search(args[2]) + func = m.group(1) if m else strip_str_literal(args[2]) + verb = strip_str_literal(args[3]).upper() + url = strip_str_literal(args[4]) + if verb not in HTTP_VERBS: + print(f" WARN: unexpected verb {verb!r} at {path.name}:{idx + 1}", + file=sys.stderr) + continue + docs.append(Doc(verb, url, func, version, f"{path.name}:{idx + 1}")) + return docs, parsed_lines + + +# -- self-check -------------------------------------------------------- + +def self_check(path: Path, parsed_lines: set[int]) -> list[str]: + """Every `\\w*resourceDocs += ResourceDoc` line must be either parsed by + the script, a `// resourceDocs += ResourceDoc` commented-out registration, + or an inline-comment mention (// comes before the pattern).""" + warnings = [] + for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines()): + if not ANY_REG_REF.search(line): + continue + if idx in parsed_lines: + continue + stripped = line.lstrip() + if stripped.startswith("//"): + continue + comment_at = line.find("//") + ref_at = line.find("resourceDocs") + # `resourceDocs` may appear after `static`; fall back to a robust check + if ref_at == -1: + ref_at = line.find("ResourceDocs") + if comment_at != -1 and comment_at < ref_at: + continue + warnings.append(f" {path.name}:{idx + 1}: {line.rstrip()}") + return warnings + + +# -- aggregation ------------------------------------------------------- + +def collect(*buckets: list[Doc]) -> list[Doc]: + merged = [d for bucket in buckets for d in bucket] + merged.sort(key=lambda d: d.version, reverse=True) # stable + seen, out = set(), [] + for d in merged: + if d.key() not in seen: + seen.add(d.key()) + out.append(d) + return out + + +# -- main -------------------------------------------------------------- + +def main() -> None: + version_files = discover_version_files() + if not version_files: + sys.exit("ERROR: no v*/Http4s*.scala or APIMethods*.scala files found") + + by_version: dict[tuple, list[Doc]] = {} + self_check_warnings: list[str] = [] + + print("Parsing ResourceDoc registrations (auto-discovered):") + for version, path in version_files: + docs, parsed = parse_file(path, version) + by_version[version] = docs + self_check_warnings += self_check(path, parsed) + print(f" v{'.'.join(map(str, version)):<8s} " + f"{path.relative_to(REPO)} {len(docs):4d} docs") + print() + + excludes: dict[tuple, set[str]] = {} + print("Extracting excludeEndpoints lists from source:") + for version, _ in version_files: + owner = find_obpapi_file(version) + if owner is None: + continue + ex = extract_excludes(owner) + if ex: + excludes[version] = ex + print(f" v{'.'.join(map(str, version))}: " + f"{len(ex)} excludes ({owner.relative_to(REPO)})") + print() + + # Chain root: OBPAPI1_2_1.allResourceDocs = Http4s121.resourceDocs (no concat). + # Every later version: collectResourceDocs(prev, this) [.filterNot(excludes)]. + versions_asc = sorted(by_version.keys()) + if not versions_asc: + sys.exit("ERROR: no parseable version files") + level: list[Doc] = list(by_version[versions_asc[0]]) + excluded_log: list[tuple[tuple, Doc]] = [] + for version in versions_asc[1:]: + level = collect(level, by_version[version]) + if version in excludes: + removed = [d for d in level if d.func in excludes[version]] + excluded_log += [(version, d) for d in removed] + level = [d for d in level if d.func not in excludes[version]] + + final = level + own_v7 = len(by_version.get((7, 0, 0), [])) + + print("=" * 66) + print("OBP endpoints reachable via /obp/v7.0.0/") + print("=" * 66) + print(f"v7.0.0 native (http4s) endpoints : {own_v7:4d}") + print(f"Total reachable (aggregated + deduped) : {len(final):4d}") + print() + + wins: dict[tuple, int] = {} + for d in final: + wins[d.version] = wins.get(d.version, 0) + 1 + print("Owned by version (newest wins on URL+verb clash):") + for v in sorted(wins, reverse=True): + print(f" v{'.'.join(map(str, v)):<14s} {wins[v]:4d}") + print() + + verbs: dict[str, int] = {} + for d in final: + verbs[d.verb] = verbs.get(d.verb, 0) + 1 + print("By HTTP method:") + for verb in sorted(verbs, key=lambda k: -verbs[k]): + print(f" {verb:<8s} {verbs[verb]:4d}") + print() + + if excluded_log: + final_keys = {d.key() for d in final} + print(f"Endpoints removed by excludeEndpoints filters: {len(excluded_log)}") + for version, d in excluded_log: + tag = " (key re-added by a later version)" if d.key() in final_keys else "" + print(f" v{'.'.join(map(str, version))} drops " + f"{d.verb:6s} {d.url} ({d.func}){tag}") + print() + + if self_check_warnings: + print("Self-check FAILED — unaccounted registration lines " + "(possible new buffer name, split-line registration, or " + "constructor-shape change):") + for w in self_check_warnings: + print(w) + sys.exit(1) + print("Self-check: every `resourceDocs += ResourceDoc` reference " + "is parsed or visibly commented.") + + +if __name__ == "__main__": + main()