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
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,58 @@
import net.liftweb.util.DefaultConnectionIdentifier

object MigrationOfMappedConsent {

val oneDayAgo = ZonedDateTime.now(ZoneId.of("UTC")).minusDays(1)
val oneYearInFuture = ZonedDateTime.now(ZoneId.of("UTC")).plusYears(1)
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'")

def alterColumnJsonWebToken(name: String): Boolean = {

private def isMssql: Boolean =
(APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver").contains("com.microsoft.sqlserver.jdbc.SQLServerDriver")

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "db.driver" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ7aVTu2EKc6hVkMC2uS&open=AZ7aVTu2EKc6hVkMC2uS&pullRequest=2843
private def isMysql: Boolean =
(APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver").contains("com.mysql.cj.jdbc.Driver")

/**
* Retype a `mappedconsent` column that the `v_consent` view projects.
*
* The `v_consent` view (created by MigrationOfConsentView) SELECTs mjsonwebtoken, mconsumerid, mstatus, etc.
* Postgres/MSSQL refuse an in-place `ALTER COLUMN ... TYPE` while a view depends on that column — even when
* the target type is unchanged ("cannot alter type of a column used by a view or rule"). So we DROP v_consent
* first, run the ALTER, then recreate the view from its canonical definition.
*
* The recreate happens HERE rather than relying on the later `addConsentView` migration: that one is gated by
* runOnce and may already be marked executed, which would otherwise leave the view permanently dropped.
* Mirrors MigrationOfConsentReferenceIdUuid's drop→alter→recreate pattern.
*/
private def alterMappedConsentColumnUnderConsentView(name: String, alterSql: => String): Boolean = {
DbFunction.tableExists(MappedConsent) match {
case true =>
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
var isSuccessful = false
val sqlLog = new StringBuilder()

try {
// 1. Drop v_consent — it projects the column, blocking the in-place retype.
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => "DROP VIEW IF EXISTS v_consent;")).append("\n")
// 2. Run the (dialect-specific) column retype.
sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => alterSql)).append("\n")
// 3. Recreate v_consent from its canonical definition (don't depend on a later, possibly-skipped migration).
MigrationOfConsentView.addConsentView(name + "_view_rebuild")
isSuccessful = true
} catch {
case e: Exception =>
isSuccessful = false
sqlLog.append(s"\nException: ${e.getMessage}\n")
}

val executedSql =
DbFunction.maybeWrite(true, Schemifier.infoF _) {
APIUtil.getPropsValue("db.driver") match {
case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") =>
() => "ALTER TABLE mappedconsent ALTER COLUMN mjsonwebtoken text;"
case Full(dbDriver) if dbDriver.contains("com.mysql.cj.jdbc.Driver") => // MySQL
() => "ALTER TABLE mappedconsent MODIFY COLUMN mjsonwebtoken TEXT;"
case _ =>
() => "ALTER TABLE mappedconsent ALTER COLUMN mjsonwebtoken type text;"
}
}

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

case false =>
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
Expand All @@ -56,7 +75,15 @@
}
}

// Widen mappedconsent.mjsonwebtoken to TEXT (projected by v_consent — see helper).
def alterColumnJsonWebToken(name: String): Boolean =
alterMappedConsentColumnUnderConsentView(name,
if (isMssql) "ALTER TABLE mappedconsent ALTER COLUMN mjsonwebtoken text;"
else if (isMysql) "ALTER TABLE mappedconsent MODIFY COLUMN mjsonwebtoken TEXT;"
else "ALTER TABLE mappedconsent ALTER COLUMN mjsonwebtoken type text;")

def alterColumnChallenge(name: String): Boolean = {
// mchallenge is NOT projected by v_consent, so this retype is not blocked by the view.
DbFunction.tableExists(MappedConsent) match {
case true =>
val startDate = System.currentTimeMillis()
Expand All @@ -79,12 +106,12 @@
"""ALTER TABLE mappedconsent ALTER COLUMN mchallenge type varchar(50);
|""".stripMargin
}

}

val endDate = System.currentTimeMillis()
val comment: String =
s"""Executed SQL:
s"""Executed SQL:
|$executedSql
|""".stripMargin
isSuccessful = true
Expand All @@ -105,93 +132,17 @@
// The mConsumerId column was originally MappedUUID (varchar(36)), but Consumer.consumerId
// is MappedString(250) and can hold composite IDs like "{azp_value}_UUID" generated
// by OAuth2.getOrCreateConsumer when the azp claim is not a UUID.
// This migration widens mConsumerId to match the Consumer model.
def alterColumnConsumerIdLength(name: String): Boolean = {
DbFunction.tableExists(MappedConsent) match {
case true =>
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
var isSuccessful = false

val executedSql =
DbFunction.maybeWrite(true, Schemifier.infoF _) {
APIUtil.getPropsValue("db.driver") match {
case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") =>
() =>
"""ALTER TABLE mappedconsent ALTER COLUMN mconsumerid varchar(250);
|""".stripMargin
case Full(dbDriver) if dbDriver.contains("com.mysql.cj.jdbc.Driver") => // MySQL
() =>
"""ALTER TABLE mappedconsent MODIFY COLUMN mconsumerid varchar(250);
|""".stripMargin
case _ =>
() =>
"""ALTER TABLE mappedconsent ALTER COLUMN mconsumerid TYPE character varying(250);
|""".stripMargin
}
}

val endDate = System.currentTimeMillis()
val comment: String =
s"""Executed SQL:
|$executedSql
|""".stripMargin
isSuccessful = true
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
}
}
// This migration widens mConsumerId to match the Consumer model. mconsumerid is projected by v_consent.
def alterColumnConsumerIdLength(name: String): Boolean =
alterMappedConsentColumnUnderConsentView(name,
if (isMssql) "ALTER TABLE mappedconsent ALTER COLUMN mconsumerid varchar(250);"
else if (isMysql) "ALTER TABLE mappedconsent MODIFY COLUMN mconsumerid varchar(250);"
else "ALTER TABLE mappedconsent ALTER COLUMN mconsumerid TYPE character varying(250);")

def alterColumnStatus(name: String): Boolean = {
DbFunction.tableExists(MappedConsent) match {
case true =>
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
var isSuccessful = false

val executedSql =
DbFunction.maybeWrite(true, Schemifier.infoF _) {
APIUtil.getPropsValue("db.driver") match {
case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") =>
() =>
"""ALTER TABLE mappedconsent ALTER COLUMN mstatus varchar(40);
|""".stripMargin
case _ =>
() =>
"""ALTER TABLE mappedconsent ALTER COLUMN mstatus type varchar(40);
|""".stripMargin
}

}

val endDate = System.currentTimeMillis()
val comment: String =
s"""Executed SQL:
|$executedSql
|""".stripMargin
isSuccessful = true
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
}
}
// mstatus is projected by v_consent.
def alterColumnStatus(name: String): Boolean =
alterMappedConsentColumnUnderConsentView(name,
if (isMssql) "ALTER TABLE mappedconsent ALTER COLUMN mstatus varchar(40);"
else if (isMysql) "ALTER TABLE mappedconsent MODIFY COLUMN mstatus varchar(40);"
else "ALTER TABLE mappedconsent ALTER COLUMN mstatus type varchar(40);")
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,58 @@ object MigrationOfMetricTable {
val startDate = System.currentTimeMillis()
val commitId: String = APIUtil.gitCommit
var isSuccessful = false
val isSqlServer = APIUtil.getPropsValue("db.driver") match {
case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => true
case _ => false
}

val executedSql =
DbFunction.maybeWrite(true, Schemifier.infoF _) {
APIUtil.getPropsValue("db.driver") match {
case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") =>
() =>
"""
|ALTER TABLE metric ALTER COLUMN correlationid varchar(256);
|""".stripMargin
case _ =>
() =>
"""
|ALTER TABLE metric ALTER COLUMN correlationid TYPE character varying(256);
|""".stripMargin
}
}
// 1. Drop the dependent view: Postgres/H2 refuse ALTER TYPE on a column referenced by a view
// (`v_metric` selects `correlationid`). The view may or may not exist when this migration runs
// — the migration log and the physical schema can diverge (e.g. a re-provisioned test DB), and
// this runs before `addMetricView` — so `DROP ... IF EXISTS` keeps it safe in every ordering.
// (Same dance as MigrationOfMetricConsumerIdFieldLength.)
val dropViewSql = DbFunction.maybeWrite(true, Schemifier.infoF _) { () =>
"DROP VIEW IF EXISTS v_metric;"
}

// 2. Widen metric.correlationid to 256.
val alterMetricSql = DbFunction.maybeWrite(true, Schemifier.infoF _) { () =>
if (isSqlServer) "ALTER TABLE metric ALTER COLUMN correlationid varchar(256);"
else "ALTER TABLE metric ALTER COLUMN correlationid TYPE character varying(256);"
}

// 3. Recreate v_metric (keep in sync with MigrationOfMetricView.addMetricView).
val createViewSql = DbFunction.maybeWrite(true, Schemifier.infoF _) { () =>
val createClause = if (isSqlServer) "CREATE OR ALTER VIEW v_metric AS" else "CREATE OR REPLACE VIEW v_metric AS"
s"""$createClause
|SELECT
| id AS metric_id,
| userid AS user_id,
| url AS url,
| date_c AS date,
| duration AS duration,
| username AS username,
| appname AS app_name,
| developeremail AS developer_email,
| consumerid AS consumer_id,
| implementedbypartialfunction AS implemented_by_partial_function,
| implementedinversion AS implemented_in_version,
| verb AS verb,
| httpcode AS http_code,
| correlationid AS correlation_id,
| responsebody AS response_body,
| sourceip AS source_ip,
| targetip AS target_ip
|FROM metric;
|""".stripMargin
}

val endDate = System.currentTimeMillis()
val comment: String =
s"""Executed SQL:
|$executedSql
s"""Executed SQL:
|$dropViewSql
|$alterMetricSql
|$createViewSql
|""".stripMargin
isSuccessful = true
saveLog(name, commitId, isSuccessful, startDate, endDate, comment)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,5 +347,45 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup {
makePatchRequest((dynamicEntity_Request / "my" / n / id).PATCH <@(user2), write(parse("""{"internal_note":"x"}"""))).code should equal(200)
} finally deleteSystemEntity(deId)
}

// Mirrors the original reproduction: a field declares an EXPLICIT, shareable write_role (rather than the
// auto-generated CanWriteDynamicEntityField_* role). Granting that role to another user lets them PATCH the
// field on the field role ALONE — no entity update role required.
scenario("Explicit write_role: a named shareable role lets another user PATCH the field alone", VersionOfApi) {
val n = "fr_explicit_role"
val explicitRole = "CanUpdateWritableExplicit" // explicit role named in the schema (cf. the ticket's CanUpdateWritable)
val (code, body) = createSystemEntity(
("entity_name" -> n) ~
("has_personal_entity" -> false) ~
("schema" -> parse(
s"""{"description":"Explicit write_role test entity.","required":["some_id"],
|"properties":{
|"some_id":{"type":"string","minLength":1,"maxLength":40,"example":"3dece208"},
|"status_code":{"type":"string","example":"verified","write_role":"$explicitRole",
|"description":"in_progress, verified, failed"}}}""".stripMargin)))
code should equal(201)
val deId = (body \ "dynamic_entity_id").extract[String]
try {
grant(createRoleFor(n)); grant(getRoleFor(n)) // user1 (creator already auto-granted, but explicit for clarity)
val createResp = makePostRequest((dynamicEntity_Request / n).POST <@(user1), write(parse("""{"some_id":"x1"}""")))
createResp.code should equal(201)
val id = recordIdFor(n, createResp.body)

When("user2 PATCHes status_code WITHOUT the explicit role")
val patch1 = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user2), write(parse("""{"status_code":"verified"}""")))
Then("We get 403 naming the explicit role (NOT the auto-generated field role)")
patch1.code should equal(403)
patch1.body.extract[ErrorMessage].message should include(explicitRole)

When("We grant the explicit role to user2 and PATCH again")
grantTo(resourceUser2.userId, explicitRole)
val patch2 = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user2), write(parse("""{"status_code":"verified"}""")))
Then("It succeeds on the explicit field role alone — no entity update role needed")
patch2.code should equal(200)
val getResp = makeGetRequest((dynamicEntity_Request / n / id).GET <@(user1))
(getResp.body \ n \ "status_code").extract[String] should equal("verified")
(getResp.body \ n \ "some_id").extract[String] should equal("x1")
} finally deleteSystemEntity(deId)
}
}
}
8 changes: 7 additions & 1 deletion obp-api/src/test/scala/code/setup/ServerSetup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import bootstrap.liftweb.ToSchemify
import code.TestServer
import code.api.util.APIUtil._
import code.api.util.{APIUtil, CustomJsonFormats}
import code.migration.MigrationScriptLog
import code.model.{Consumer, Nonce, Token}
import code.model.dataAccess.{AuthUser, ResourceUser}
import code.util.Helper.MdcLoggable
Expand Down Expand Up @@ -131,7 +132,12 @@ trait ServerSetup extends FeatureSpec with SendServerRequests
*/
protected def resetDatabaseForTestClass(): Unit = {
def exclusion(m: MetaMapper[_]): Boolean = {
m == Nonce || m == Token || m == Consumer || m == AuthUser || m == ResourceUser
// MigrationScriptLog is migration bookkeeping, not test data. Wiping it makes isExecuted always
// false, so every fresh `mvn test` JVM re-runs all migrations against a DB that already has the
// migration-created views (v_consent, v_metric, …) — and an in-place column retype on a
// view-projected column then fails ("cannot alter type of a column used by a view or rule"),
// aborting boot until the DB is manually reset. Preserve it so migrations run once per DB.
m == Nonce || m == Token || m == Consumer || m == AuthUser || m == ResourceUser || m == MigrationScriptLog
}

logger.info(s"[TEST ISOLATION] Resetting database before test class: ${this.getClass.getSimpleName}")
Expand Down
Loading