diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 82ba3abbfb..b65748e5ee 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1557,6 +1557,21 @@ personal_data_collection_consent_country_waiver_list = Austria, Belgium, Bulgari # user_invitation_link_base_URL=strongly recommended to use top level domain name so that all nodes in the cluster share same URL +# --- Dynamic code execution kill-switch (security: RCE surface) --- +# Master switch for USER-GENERATED code that OBP compiles and executes at runtime: +# - Dynamic Resource Docs (/management/dynamic-resource-docs) -> compiles Scala +# - Connector Methods (/management/connector-methods) -> compiles Scala/JS/Java +# - Dynamic Message Docs (/management/dynamic-message-docs) -> compiles Scala/JS/Java +# - ABAC Rules (/management/abac-rules) -> compiles Scala +# When false, these endpoints return OBP-50020 and NOTHING is compiled or executed. +# This does NOT affect Dynamic Entities or Swagger Dynamic Endpoints (they store data / +# proxy HTTP and do not execute code). +# SECURITY: the runtime sandbox (SecurityManager) is a no-op on JDK 24+ (JEP 486) and the +# GraalVM JS context is created with HostAccess.ALL, so enabled dynamic code runs UNSANDBOXED. +# Leave false in production. Only enable on trusted sandbox/dev instances. +# (Always ON in test/dev run modes regardless of this value, unless this prop is set explicitly.) +allow_user_generated_scala_code=false + # enable dynamic code sandbox, default is false, this will make sandbox works for code running in Future, will make performance lower than disable dynamic_code_sandbox_enable=false # Here is the default permissions if you set the dynamic_code_sandbox_enable = true. If you need more permission need to add it manually here. diff --git a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala index e126088280..fdf4fa7661 100644 --- a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala @@ -32,6 +32,14 @@ import scala.tools.reflect.{ToolBox, ToolBoxError} object DynamicUtil extends MdcLoggable{ + // Master kill-switch for user-generated dynamic code (RCE surface). Explicit prop always + // wins so tests can force OFF; absent the prop, ON in test/dev, OFF in production. + def dynamicCodeExecutionEnabled: Boolean = + APIUtil.getPropsValue("allow_user_generated_scala_code") match { + case Full(v) => v.toBoolean + case _ => net.liftweb.util.Props.testMode || net.liftweb.util.Props.devMode + } + val toolBox: ToolBox[universe.type] = runtimeMirror(getClass.getClassLoader).mkToolBox() private val memoClassPool = new Memo[ClassLoader, ClassPool] @@ -54,6 +62,14 @@ object DynamicUtil extends MdcLoggable{ * @return compiled Full[function|object|class] or Failure */ def compileScalaCode[T](code: String): Box[T] = { + if (!dynamicCodeExecutionEnabled) + return Failure(ErrorMessages.DynamicCodeExecutionDisabled) + compileScalaCodeUnchecked[T](code) + } + + // Used ONLY by DynamicUtil.Validation's props-driven config parsing (operator config, + // not user-generated code) so the app can still boot with the kill-switch off. + private def compileScalaCodeUnchecked[T](code: String): Box[T] = { logger.trace(s"code.api.util.DynamicUtil.compileScalaCode.size is ${dynamicCompileResult.size()}") val compiledResult: Box[Any] = dynamicCompileResult.computeIfAbsent(code, _ => { val tree = try { @@ -344,7 +360,7 @@ object DynamicUtil extends MdcLoggable{ val dynamicCodeSandboxPermissions = APIUtil.getPropsValue("dynamic_code_sandbox_permissions", "[]").trim val scalaCodePermissioins = "List[java.security.Permission]"+dynamicCodeSandboxPermissions.replaceFirst("\\[","(").dropRight(1)+")" - val permissions:Box[List[java.security.Permission]] = DynamicUtil.compileScalaCode(scalaCodePermissioins) + val permissions:Box[List[java.security.Permission]] = DynamicUtil.compileScalaCodeUnchecked(scalaCodePermissioins) // all Permissions put at here // Here is the Java Permission document, please extend these permissions carefully. @@ -368,7 +384,7 @@ object DynamicUtil extends MdcLoggable{ val dependenciesString = APIUtil.getPropsValue("dynamic_code_compile_validate_dependencies", "[]").trim val scalaCodeDependencies = s"${DynamicUtil.importStatements}"+dependenciesString.replaceFirst("\\[","Map(").dropRight(1) +").mapValues(v => StringUtils.split(v, ',').map(_.trim).toSet)" - val dependenciesBox: Box[Map[String, Set[String]]] = DynamicUtil.compileScalaCode(scalaCodeDependencies) + val dependenciesBox: Box[Map[String, Set[String]]] = DynamicUtil.compileScalaCodeUnchecked(scalaCodeDependencies) /** * Compilation OBP Dependencies Guard, only checked the OBP methods, not scala/Java libraies(are checked during the runtime.). @@ -452,7 +468,9 @@ object DynamicUtil extends MdcLoggable{ private val memoDynamicFunction = new Memo[String, Box[DynamicFunction]] - def createJsFunction(methodBody:String, bindingVars: Map[String, AnyRef] = Map.empty): Box[DynamicFunction] = memoDynamicFunction.memoize("Javascript:" + methodBody) { + def createJsFunction(methodBody:String, bindingVars: Map[String, AnyRef] = Map.empty): Box[DynamicFunction] = + if (!dynamicCodeExecutionEnabled) Failure(ErrorMessages.DynamicCodeExecutionDisabled) + else memoDynamicFunction.memoize("Javascript:" + methodBody) { Box tryo { val jsCode = s"""async function processor(args, callContext) { $methodBody @@ -495,7 +513,9 @@ object DynamicUtil extends MdcLoggable{ private val javaEngine = (new ScriptEngineManager).getEngineByName("java") - def createJavaFunction(methodBody:String): Box[DynamicFunction] = memoDynamicFunction.memoize("java:" + methodBody) { + def createJavaFunction(methodBody:String): Box[DynamicFunction] = + if (!dynamicCodeExecutionEnabled) Failure(ErrorMessages.DynamicCodeExecutionDisabled) + else memoDynamicFunction.memoize("java:" + methodBody) { import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.util.JsonAliases.compactRender diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 3d73463404..c55f7a9f2d 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -897,6 +897,7 @@ object ErrorMessages { val NotAllowedEndpoint = "OBP-50017: The endpoint is not enabled at this OBP API instance." val UnderConstructionError = "OBP-50018: Under Construction Error." val DatabaseConnectionClosedError = "OBP-50019: Cannot connect to the OBP database." + val DynamicCodeExecutionDisabled = "OBP-50020: User-generated dynamic code execution is disabled on this API instance. Set allow_user_generated_scala_code=true to enable." // Connector Data Exceptions (OBP-502XX) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 2499d1ae1e..d8aee0340b 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -48,6 +48,7 @@ import code.model.BankX import code.api.JsonResponseException import code.api.util.AuthenticationType import code.api.util.CommonsEmailWrapper.{EmailContent, sendHtmlEmail} +import code.api.util.DynamicUtil import code.api.util.DynamicUtil.Validation import code.api.dynamic.endpoint.helper.CompiledObjects import code.api.dynamic.endpoint.helper.practise.DynamicEndpointCodeGenerator @@ -9163,6 +9164,7 @@ object Http4s400 { val cc = req.callContext val rawBody = cc.httpBody.getOrElse("") for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } jsonConnectorMethod <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[JsonConnectorMethod].getSimpleName}", 400, Some(cc)) { @@ -9192,6 +9194,7 @@ object Http4s400 { EndpointHelpers.executeAndRespond(req) { cc => val rawBody = cc.httpBody.getOrElse("") for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } connectorMethodBody <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[JsonConnectorMethod].getSimpleName}", 400, Some(cc)) { @@ -9373,6 +9376,7 @@ object Http4s400 { private def createDynamicResourceDocImpl(bankId: Option[String], rawBody: String, cc: CallContext): Future[JsonDynamicResourceDoc] = { for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } body <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicResourceDoc].getSimpleName}", 400, Some(cc)) { @@ -9391,6 +9395,7 @@ object Http4s400 { private def updateDynamicResourceDocImpl(bankId: Option[String], dynamicResourceDocId: String, rawBody: String, cc: CallContext): Future[JsonDynamicResourceDoc] = { for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } body <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicResourceDoc].getSimpleName}", 400, Some(cc)) { @@ -9671,6 +9676,7 @@ object Http4s400 { private def createDynamicMessageDocImpl(bankId: Option[String], rawBody: String, cc: CallContext): Future[JsonDynamicMessageDoc] = { for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } body <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicMessageDoc].getSimpleName}", 400, Some(cc)) { @@ -9693,6 +9699,7 @@ object Http4s400 { private def updateDynamicMessageDocImpl(bankId: Option[String], dynamicMessageDocId: String, rawBody: String, cc: CallContext): Future[JsonDynamicMessageDoc] = { for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } body <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicMessageDoc].getSimpleName}", 400, Some(cc)) { diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index fbe05e4793..5905701cf2 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -71,6 +71,7 @@ import code.api.v6_0_0.JSONFactory600.UpdateViewJsonV600 import code.model._ import code.model.dataAccess.AuthUser import code.users.{Users, DoobieUserQueries} +import code.api.util.DynamicUtil import code.util.Helper.SILENCE_IS_GOLDEN import com.openbankproject.commons.dto.GetProductsParam import code.model.ModeratedTransaction @@ -4588,6 +4589,7 @@ object Http4s600 { EndpointHelpers.executeAndRespond(req) { implicit cc => val rawBody = cc.httpBody.getOrElse("") for { + _ <- code.util.Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } validateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[ValidateAbacRuleJsonV600] } @@ -5488,6 +5490,7 @@ object Http4s600 { val rawBody = cc.httpBody.getOrElse("") val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) for { + _ <- Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } createJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CreateAbacRuleJsonV600] } @@ -5542,6 +5545,7 @@ object Http4s600 { val rawBody = cc.httpBody.getOrElse("") val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) for { + _ <- Helper.booleanToFuture(DynamicCodeExecutionDisabled, cc = Some(cc)) { DynamicUtil.dynamicCodeExecutionEnabled } updateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[UpdateAbacRuleJsonV600] } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala new file mode 100644 index 0000000000..205987e57f --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala @@ -0,0 +1,247 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + */ +package code.api.v4_0_0 + +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.util.ApiRole._ +import code.api.util.ErrorMessages.DynamicCodeExecutionDisabled +import code.api.util.{ApiRole, DynamicUtil} +import code.connectormethod.{ConnectorMethodProvider, JsonConnectorMethod} +import code.dynamicResourceDoc.JsonDynamicResourceDoc +import code.entitlement.Entitlement +import code.setup.OBPReq +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.{Failure, Full} +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +/** + * Covers the RCE kill-switch (allow_user_generated_scala_code) added around + * code.api.util.DynamicUtil.dynamicCodeExecutionEnabled. Exercises the predicate + * directly and each of the three http4s chokepoints it guards (connector methods, + * dynamic resource docs, ABAC rules), plus a regression check that Dynamic Entities + * (which never execute user code) are unaffected when the switch is off. + */ +class DynamicCodeKillSwitchTest extends V400ServerSetup { + + def v6_0_0_Request: OBPReq = baseRequest / "obp" / "v6.0.0" + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + + override def beforeEach(): Unit = { + super.beforeEach() + setPropsValues("starConnector_supported_types" -> "mapped,internal") + setPropsValues("connector" -> "star") + } + + feature("DynamicUtil.dynamicCodeExecutionEnabled predicate") { + + scenario("Defaults to enabled in test mode when the prop is absent", VersionOfApi) { + Then("the predicate should be true (we are running under testMode)") + DynamicUtil.dynamicCodeExecutionEnabled should be(true) + + And("compileScalaCode should still compile and execute") + val result = DynamicUtil.compileScalaCode[Int]("41 + 1") + result should be(Full(42)) + } + + scenario("Explicit prop=false overrides testMode and disables compilation", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "false") + + Then("the predicate should be false") + DynamicUtil.dynamicCodeExecutionEnabled should be(false) + + And("compileScalaCode should refuse to compile/execute and return the kill-switch Failure") + val result = DynamicUtil.compileScalaCode[Int]("41 + 1") + result should be(Failure(DynamicCodeExecutionDisabled)) + } + + scenario("Explicit prop=true overrides a false default", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "true") + + Then("the predicate should be true") + DynamicUtil.dynamicCodeExecutionEnabled should be(true) + } + } + + feature("Connector Methods endpoint respects the kill-switch") { + + scenario("OFF: create connector method returns 400 with the kill-switch error, nothing persisted", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateConnectorMethod.toString) + + val countBefore = ConnectorMethodProvider.provider.vend.getAll().size + + val request = (v4_0_0_Request / "management" / "connector-methods").POST <@ (user1) + lazy val postConnectorMethod = SwaggerDefinitionsJSON.jsonScalaConnectorMethod + + val response = makePostRequest(request, write(postConnectorMethod)) + + Then("We should get a 400, not a 500 and not a 201") + response.code should equal(400) + response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + + And("nothing should have been persisted") + ConnectorMethodProvider.provider.vend.getAll().size should equal(countBefore) + } + + scenario("ON: create connector method returns 201 and is persisted", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateConnectorMethod.toString) + + val countBefore = ConnectorMethodProvider.provider.vend.getAll().size + + val request = (v4_0_0_Request / "management" / "connector-methods").POST <@ (user1) + lazy val postConnectorMethod = SwaggerDefinitionsJSON.jsonScalaConnectorMethod + + val response = makePostRequest(request, write(postConnectorMethod)) + + Then("We should get a 201") + response.code should equal(201) + val connectorMethod = response.body.extract[JsonConnectorMethod] + connectorMethod.connectorMethodId shouldNot be(null) + + And("it should be persisted") + ConnectorMethodProvider.provider.vend.getAll().size should equal(countBefore + 1) + } + } + + feature("Dynamic Resource Doc endpoint respects the kill-switch") { + + scenario("OFF: create dynamic resource doc returns 400 with the kill-switch error", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + + val request = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + lazy val postDynamicResourceDoc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(dynamicResourceDocId = None) + + val response = makePostRequest(request, write(postDynamicResourceDoc)) + + Then("We should get a 400, not a 500 and not a 201") + response.code should equal(400) + response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + } + + scenario("ON: create dynamic resource doc returns 201", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + + val request = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + lazy val postDynamicResourceDoc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(dynamicResourceDocId = None) + + val response = makePostRequest(request, write(postDynamicResourceDoc)) + + Then("We should get a 201") + response.code should equal(201) + val dynamicResourceDoc = response.body.extract[JsonDynamicResourceDoc] + dynamicResourceDoc.dynamicResourceDocId shouldNot be(null) + } + } + + feature("ABAC Rule endpoint respects the kill-switch") { + + scenario("OFF: create ABAC rule returns 400 with the kill-switch error", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) + + val createJson = code.api.v6_0_0.CreateAbacRuleJsonV600( + rule_name = "kill-switch-off-test", + rule_code = "true", + description = "should not compile", + policy = "account-access", + is_active = true + ) + val request = (v6_0_0_Request / "management" / "abac-rules").POST <@ (user1) + val response = makePostRequest(request, write(createJson)) + + Then("We should get a 400, not a 500 and not a 201") + response.code should equal(400) + response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + } + + scenario("ON: create ABAC rule returns 201", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) + + // "false" (deny-all) is used rather than "true" because the ABAC engine rejects + // "true" as a tautology (see AbacRuleTests "Tautology Detection" scenarios) — + // that would fail this scenario for a reason unrelated to the kill-switch. + val createJson = code.api.v6_0_0.CreateAbacRuleJsonV600( + rule_name = "kill-switch-on-test", + rule_code = "false", + description = "should compile", + policy = "account-access", + is_active = true + ) + val request = (v6_0_0_Request / "management" / "abac-rules").POST <@ (user1) + val response = makePostRequest(request, write(createJson)) + + Then("We should get a 201") + response.code should equal(201) + (response.body \ "abac_rule_id").extract[String] shouldNot be("") + } + } + + feature("Dynamic Entities are unaffected by the kill-switch (no over-reach)") { + + scenario("OFF: create Dynamic Entity still succeeds because it never compiles user code", VersionOfApi) { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + val entityJson = org.json4s.native.JsonMethods.parse( + """ + |{ + | "entity_name": "kill_switch_regression_entity", + | "has_personal_entity": true, + | "schema": { + | "description": "regression entity for the dynamic code kill-switch", + | "required": ["name"], + | "properties": { + | "name": { + | "type": "string", + | "example": "James Brown" + | } + | } + | } + |} + |""".stripMargin) + + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1) + val response = makePostRequest(request, write(entityJson)) + + Then("We should still get a 201 — Dynamic Entities do not execute user code") + response.code should equal(201) + + val dynamicEntityId = (response.body \ "dynamic_entity_id").extract[String] + + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@ (user1) + makeDeleteRequest(deleteRequest) + } + } + +}