diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala index ecb043b779..9be260d06b 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala @@ -11,39 +11,58 @@ import net.liftweb.mapper.{DB, Schemifier} 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") + 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 @@ -56,7 +75,15 @@ object MigrationOfMappedConsent { } } + // 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() @@ -79,12 +106,12 @@ object MigrationOfMappedConsent { """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 @@ -105,93 +132,17 @@ object MigrationOfMappedConsent { // 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);") } diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricTable.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricTable.scala index 431de1766a..e4a32f9835 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricTable.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricTable.scala @@ -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) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala index 9371189401..edcdc29630 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala @@ -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) + } } } diff --git a/obp-api/src/test/scala/code/setup/ServerSetup.scala b/obp-api/src/test/scala/code/setup/ServerSetup.scala index fc53ba0d9d..199209715e 100644 --- a/obp-api/src/test/scala/code/setup/ServerSetup.scala +++ b/obp-api/src/test/scala/code/setup/ServerSetup.scala @@ -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 @@ -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}")