From 41929cc4bdbb26baf4cb7f983aa7290f96794ec0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 23 Sep 2026 22:53:35 +0200 Subject: [PATCH 1/8] Rename of props to dynamic_code_obp_calls_are_restricted and removal of JVM sandbox Props. DynamicCodeBody. Correcting some roles requiresBankId settings. Rename of props to dynamic_code_allowed_obp_methods --- docs/MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md | 2 +- .../resources/props/sample.props.template | 52 +++-- .../main/scala/bootstrap/liftweb/Boot.scala | 3 - .../endpoint/Http4sDynamicEndpoint.scala | 2 +- .../helper/DynamicCompileEndpoint.scala | 7 +- .../endpoint/helper/DynamicEndpoints.scala | 21 +- .../DynamicResourceDocsEndpointGroup.scala | 2 +- .../helper/practise/PractiseEndpoint.scala | 1 - .../main/scala/code/api/util/APIUtil.scala | 129 ++++++++--- .../main/scala/code/api/util/ApiRole.scala | 24 +- .../scala/code/api/util/DynamicUtil.scala | 172 +++++---------- .../main/scala/code/api/util/Glossary.scala | 1 + .../scala/code/api/v6_0_0/Http4s600.scala | 8 +- .../scala/code/api/v7_0_0/Http4s700.scala | 2 +- .../dynamicEntity/DynamicEntityProvider.scala | 71 ++++-- .../MapppedDynamicDataProvider.scala | 19 +- .../api/sweep/AnyBankScopeSweepTest.scala | 205 ++++++++++++++++++ .../code/api/v3_0_0/GetAdapterInfoTest.scala | 13 +- .../api/v4_0_0/DynamicResourceDocTest.scala | 2 +- .../v5_1_0/JustInTimeEntitlementsTest.scala | 136 ++++++++++++ .../v6_0_0/CounterpartyAttributeTest.scala | 27 ++- .../DynamicEntityReferenceSpaceTest.scala | 157 ++++++++++++++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 13 +- .../scala/code/util/DynamicUtilTest.scala | 88 +------- .../commons/ExecutionContext.scala | 11 +- release_notes.md | 101 +++++++++ 26 files changed, 924 insertions(+), 345 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityReferenceSpaceTest.scala diff --git a/docs/MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md b/docs/MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md index 7e1ecbc290..e208b367bd 100644 --- a/docs/MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md +++ b/docs/MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md @@ -17,7 +17,7 @@ The technical sandbox no longer provides a meaningful second line of defence: enforces nothing (`DynamicUtil.scala:253-259` logs this). - The GraalVM JavaScript context is created with `HostAccess.ALL`, `PolyglotAccess.ALL` and unrestricted host class lookup (`DynamicUtil.scala:486-529`). -- The dependency validator (`dynamic_code_compile_validate_enable`) is off by default and only +- The OBP call allowlist (`dynamic_code_obp_calls_are_restricted`) is off by default and only blocks reflection and `ExecutionContext`; it has no notion of file, network or process access. So a single holder of `CanCreateDynamicResourceDoc` or `CanCreateConnectorMethod` has remote code diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 38eef34cf2..0bc67549c9 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1335,6 +1335,7 @@ create_just_in_time_entitlements=false # This speeds up the process of granting of roles. Certain roles are excluded from this automation: # - CanCreateEntitlementAtOneBank # - CanCreateEntitlementAtAnyBank +# Nothing is granted this way in the system space (the space whose bank id is the literal SYS): a Role there is always granted by hand. # If create_just_in_time_entitlements is again set to false after it was true for a while, any auto granted Entitlements to roles are kept in place. # Note: In the entitlements model we set createdbyprocess="create_just_in_time_entitlements". For manual operations we set createdbyprocess="manual" # ------------------------------------------------------------- @@ -1776,29 +1777,34 @@ personal_data_collection_consent_country_waiver_list = Austria, Belgium, Bulgari # it must be set to true explicitly, there is no run-mode-based fallback. 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. -# Better search for comment code `val allowedRuntimePermissions = List[Permission]( ....` need to provide the fully qualified class name and proper parameters. -dynamic_code_sandbox_permissions=[\ - new java.net.NetPermission("specifyStreamHandler"),\ - new java.lang.reflect.ReflectPermission("suppressAccessChecks"),\ - new java.lang.RuntimePermission("getenv.*"),\ - new java.util.PropertyPermission("cglib.useCache", "read"),\ - new java.util.PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"),\ - new java.util.PropertyPermission("cglib.debugLocation", "read"),\ - new java.lang.RuntimePermission("accessDeclaredMembers"),\ - new java.lang.RuntimePermission("getClassLoader")\ -] - - -# enable dynamic code compile validation, default is false, if set it to true, it will validate all the dynamic method body when you create/update any -# dynamic scala method. Note, it only check all the obp code dependents for all the method in OBP code. -dynamic_code_compile_validate_enable=false -# The default support dependencies if set dynamic_code_compile_validate_enable = true. it can be the class level or the method level, -# you can add them in the following list. Better check search for comment code: val allowedCompilationMethods: Map[String, Set[String]] = Map( ... -# need to prepare the correct OBP scala code. -dynamic_code_compile_validate_dependencies=[\ +# NOTE: dynamic_code_sandbox_enable and dynamic_code_sandbox_permissions were removed. +# They wrapped dynamic code in AccessController.doPrivileged with a restricted permission +# set, which stopped being enforceable when SecurityManager was removed in JDK 24 (JEP 486); +# OBP requires JVM 25, so the sandbox could never restrict file, network or reflection +# access while still costing a privileged wrapper on every dynamic call. Dynamic code runs +# with the full privileges of the OBP process. The controls that do work are +# allow_user_generated_scala_code (below), dynamic_code_compile_validate_enable and +# dynamic_code_requires_approval. + + +# When true, dynamic code may call ONLY the OBP methods listed in +# dynamic_code_allowed_obp_methods below; creating or updating a body that calls anything +# else is rejected with OBP-40047 naming the offending method. It is an allowlist on OBP's +# own API surface -- NOT a sandbox: it does not restrict file, network or reflection access, +# and it does not check general scala/java library calls. +# +# Renamed from dynamic_code_compile_validate_enable, which read as "check that it compiles". +# The old name is still honoured with a deprecation warning at boot. +# +# Defaults to false, so dynamic code may call any OBP method. Only relevant once +# allow_user_generated_scala_code is true. +dynamic_code_obp_calls_are_restricted=false +# The allowlist used when the above is true: typeName -> allowed methods ("*" for all). +# Renamed from dynamic_code_compile_validate_dependencies. Search the code for +# `val allowedCompilationMethods` for how it is parsed; entries must be valid OBP scala. +# NOTE: this default list allows no data access, so an endpoint that reads dynamic entities +# (e.g. code.DynamicData.DynamicDataProvider) must be added here before it can be created. +dynamic_code_allowed_obp_methods=[\ NewStyle.function.getClass.getTypeName -> "*",\ CompiledObjects.getClass.getTypeName -> "sandbox",\ HttpCode.getClass.getTypeName -> "200",\ diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 90b92794eb..5f4426bcd1 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -239,9 +239,6 @@ class Boot extends MdcLoggable { logger.info("Current Project TimeZone: " + TimeZone.getDefault) - // set dynamic_code_sandbox_enable to System.properties, so com.openbankproject.commons.ExecutionContext can read this value - APIUtil.getPropsValue("dynamic_code_sandbox_enable") - .foreach(it => System.setProperty("dynamic_code_sandbox_enable", it)) } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala index b1440d5af7..5bb72002da 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/Http4sDynamicEndpoint.scala @@ -58,7 +58,7 @@ import org.http4s.{HttpRoutes, Request, Response} * `code.api.dynamic.endpoint.helper.DynamicEndpoints.CompiledObjects` / `DynamicCompileEndpoint`). * The doc's auth/validation chain (`ResourceDoc.authCheckIO`, the native mirror of * `wrappedWithAuthCheck`) runs first, then the handler runs inside the dynamic-code security - * sandbox (`Sandbox.runInSandboxIO`, applied inside the compiled handler). + * body forcing / early-return recovery (`DynamicCodeBody.force`, inside the compiled handler). * * Piece B is tried first; a non-match falls through to Piece C; a non-match there returns * `OptionT.none`, so the request falls through the Http4sApp chain (the Lift bridge produces the diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala index 5d7a8b64bd..8a3ab75b92 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicCompileEndpoint.scala @@ -31,7 +31,7 @@ import org.json4s._ import scala.language.implicitConversions import cats.effect.IO import code.api.util.APIUtil.{Http4sEndpointIO, OBPReturnType} -import code.api.util.DynamicUtil.{Sandbox, Validation} +import code.api.util.DynamicUtil.{DynamicCodeBody, Validation} import code.api.util.{CallContext, CustomJsonFormats, DynamicUtil} import org.http4s.{Request, Response} @@ -49,9 +49,6 @@ import org.http4s.{Request, Response} trait DynamicCompileEndpoint { implicit val formats = CustomJsonFormats.formats - // * is any bankId - val boundBankId: String - protected def process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]): IO[Response[IO]] val endpoint: Http4sEndpointIO = new Http4sEndpointIO { @@ -62,7 +59,7 @@ trait DynamicCompileEndpoint { validateDependencies() - Sandbox.sandbox(boundBankId).runInSandboxIO { + DynamicCodeBody.force { process(cc, request, pathParams) } } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala index 183ab04ad2..e3e3e99191 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicEndpoints.scala @@ -31,7 +31,7 @@ import org.json4s._ import cats.effect.IO import code.api.dynamic.endpoint.helper.practise.{DynamicEndpointCodeGenerator, PractiseEndpointGroup} import code.api.dynamic.endpoint.helper.practise.PractiseEndpointGroup -import code.api.util.DynamicUtil.{Sandbox, Validation} +import code.api.util.DynamicUtil.{DynamicCodeBody, Validation} import code.api.util.APIUtil.{BooleanBody, DoubleBody, EmptyBody, LongBody, Http4sEndpointIO, PrimaryDataBody, ResourceDoc, StringBody, getDisabledEndpointOperationIds} import code.api.util.{CallContext, DynamicUtil} import net.liftweb.common.{Box, Failure, Full} @@ -248,28 +248,21 @@ case class CompiledObjects(exampleRequestBody: Option[JValue], successResponseBo CompiledObjects.compileProblems(exampleRequestBody, successResponseBody, methodBody) /** - * This is used to check the security permission at the run time. - * all the obp partialFunctions will be wrapped into the sandbox which under the permission control. - * + * Wraps the compiled partial function as an endpoint. This used to bind a per-bank + * security sandbox; that sandbox could not be enforced on JDK 24+ and has been + * removed (see DynamicUtil.DynamicCodeBody), so what remains is forcing the body and + * recovering an early `return` from user code. */ - def sandboxEndpoint(bankId: Option[String]) : Http4sEndpointIO = { - val sandbox = bankId match { - case Some(v) if StringUtils.isNotBlank(v) => - Sandbox.sandbox(v) - case _ => Sandbox.sandbox("*") - } - + def compiledEndpoint() : Http4sEndpointIO = new Http4sEndpointIO { override def isDefinedAt(req: Request[IO]): Boolean = partialFunction.isDefinedAt(req) - // run dynamic code in sandbox override def apply(req: Request[IO]): CallContext => IO[Response[IO]] = { cc => val fn = partialFunction.apply(req) - sandbox.runInSandboxIO(fn(cc)) + DynamicCodeBody.force(fn(cc)) } } - } private def toCaseObject(jValue: Option[JValue]): Product = CompiledObjects.toCaseObject(jValue) } diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala index bf29cd0205..0a15a2f511 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/DynamicResourceDocsEndpointGroup.scala @@ -84,7 +84,7 @@ object DynamicResourceDocsEndpointGroup extends EndpointGroup with code.util.Hel ResourceDoc( // partialFunction is a no-op stub — the runtime dispatch uses the native handler in // dynamicHttp4sFunction (the compiled artifact is OBPEndpointIO, not the Lift OBPEndpoint). - dynamicHttp4sFunction = Some(compiledObjects.sandboxEndpoint(dynamicDoc.bankId)), + dynamicHttp4sFunction = Some(compiledObjects.compiledEndpoint()), implementedInApiVersion = apiVersion, partialFunctionName = dynamicDoc.partialFunctionName + "_" + (dynamicDoc.requestVerb + dynamicDoc.requestUrl).hashCode, requestVerb = dynamicDoc.requestVerb, diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala index ec410dcddd..d4065a0cc4 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/PractiseEndpoint.scala @@ -72,7 +72,6 @@ object PractiseEndpoint extends DynamicCompileEndpoint { case class ResponseRootJsonClass(my_user_id: String, name: String, age: Long, hobby: List[String]) // * is any bankId, if bound to other bankId, just modify this value to correct one - override val boundBankId = "*" // copy the whole method body as "dynamicResourceDoc" method body override protected def diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 1ef2aa71be..bac46c035c 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2318,6 +2318,100 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def hasAtLeastOneEntitlement(bankId: String, userId: String, roles: List[ApiRole]): Boolean = roles.isEmpty || roles.exists(hasEntitlement(bankId, userId, _)) + /** + * Grant a caller the Roles they are missing, at the moment they first need them. + * + * This is the "just in time" entitlement described in the Glossary and switched on with the + * create_just_in_time_entitlements prop. The idea is that a caller who could have granted + * themselves a Role by hand, because they hold one of the granting Roles, should not have to make + * that second call: OBP writes the Entitlement for them and lets the request through. The row is + * an ordinary Entitlement, marked with created_by_process = "create_just_in_time_entitlements" so + * that an operator reading the table later can tell it apart from a hand granted one. + * + * Two callers are never granted anything this way. A consent user is refused because a Role held + * by a per-consent identity would be stranded there rather than belonging to the human. And the + * system space is refused because reaching it is meant to be a deliberate act: a Dynamic Entity + * at the bank id DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID is the instance wide space, and granting + * anything in it requires the system space granting Role rather than the ordinary per bank one. + * Without this second exclusion, holding the per bank granting Role at that one bank id would be + * enough to collect system space Roles silently, one endpoint call at a time. + * + * Returns true only when every missing Role was granted, which is what lets the caller through. + */ + /** + * The two Roles that hand out Roles are never granted automatically. Granting one of them to a + * caller who is already using this automation would let a caller widen their own granting reach + * without anybody deciding to let them, which is the one thing the manual process is there for. + * The Glossary Item Entitlement and sample.props.template both state this exclusion. + */ + private val rolesNeverGrantedJustInTime: Set[String] = + Set(ApiRole.canCreateEntitlementAtOneBank.toString, ApiRole.canCreateEntitlementAtAnyBank.toString) + + /** + * Grant a caller the Roles they are missing, at the moment they first need them. + * + * This is the "just in time" entitlement described in the Glossary and switched on with the + * create_just_in_time_entitlements prop. The idea is that a caller who could have granted + * themselves a Role by hand, because they hold one of the granting Roles, should not have to make + * that second call: OBP writes the Entitlement for them and lets the request through. The row is + * an ordinary Entitlement, marked with created_by_process = "create_just_in_time_entitlements" so + * that an operator reading the table later can tell it apart from a hand granted one. + * + * "Could have granted it by hand" is meant literally, and is what the checks below reproduce. + * Add Entitlement writes a Role at the scope the Role itself declares: a Role with + * requiresBankId = false lives at the system scope and is refused if a bank id is supplied, and a + * Role with requiresBankId = true is refused without one. The granting Role needed differs the + * same way, because only a holder of CanCreateEntitlementAtAnyBank can write at the system scope. + * So a caller who may grant Entitlements at one bank gets bank Roles at that bank and nothing + * else, and in particular gets no system scoped Role, however the endpoint they called was + * addressed. Whether the caller may then proceed is decided by reading the Entitlements back at + * the scope the check uses, never by the write having succeeded: a row written at the wrong scope + * is a row no check will ever read, so treating the write as the answer would let a caller past a + * Role they do not hold and cannot obtain. + * + * Two callers are never granted anything this way. A consent user is refused because a Role held + * by a per-consent identity would be stranded there rather than belonging to the human. And the + * system space is refused because reaching it is meant to be a deliberate act: a Dynamic Entity + * at the bank id DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID is the instance wide space, and granting + * anything in it requires the system space granting Role rather than the ordinary per bank one. + * Without this second exclusion, holding the per bank granting Role at that one bank id would be + * enough to collect system space Roles silently, one endpoint call at a time. + */ + private def grantJustInTimeEntitlements(bankId: String, userId: String, roles: List[ApiRole]): Boolean = { + if (!getPropsAsBoolValue("create_just_in_time_entitlements", false) || + isConsentUser(userId) || + bankId == DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) { + false + } else { + val mayGrantAtThisBank = hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) + val mayGrantAtEveryBankAndAtTheSystemScope = hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank) + + roles.foreach { role => + val scopeAddEntitlementWouldUse = if (role.requiresBankId) bankId else "" + val couldHaveBeenGrantedByHand = role match { + // A combination is a way of writing "all of these at once" for a check; it is not a Role + // anybody can hold, so there is no row to write for it. + case _: RoleCombination => false + case _ if rolesNeverGrantedJustInTime.contains(role.toString) => false + case _ if role.requiresBankId => bankId.nonEmpty && (mayGrantAtThisBank || mayGrantAtEveryBankAndAtTheSystemScope) + case _ => mayGrantAtEveryBankAndAtTheSystemScope + } + if (couldHaveBeenGrantedByHand && !hasEntitlement(bankId, userId, role)) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement( + scopeAddEntitlementWouldUse, + userId, + role.toString, + "create_just_in_time_entitlements", + grantedByUserId = Some(userId) + ) + logger.info(s"Just in Time Entitlements: $addedEntitlement") + } + } + + roles.exists(hasEntitlement(bankId, userId, _)) + } + } + // Function checks does a user specified by a parameter userId has at least one role provided by a parameter roles at a bank specified by a parameter bankId // i.e. does user has assigned at least one role from the list // when roles is empty, that means no access control, treat as pass auth check @@ -2338,25 +2432,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def userHasTheRoles: Boolean = { val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) - userHasTheRole || { - getPropsAsBoolValue("create_just_in_time_entitlements", false) && !isConsentUser(userId) && { - // If a user is trying to use a Role and the user could grant them selves the required Role(s), - // then just automatically grant the Role(s)! - (hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) || - hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) && - roles.forall { role => - val addedEntitlement = Entitlement.entitlement.vend.addEntitlement( - bankId, - userId, - role.toString, - "create_just_in_time_entitlements", - grantedByUserId = Some(userId) - ) - logger.info(s"Just in Time Entitlements: $addedEntitlement") - addedEntitlement.isDefined - } - } - } + userHasTheRole || grantJustInTimeEntitlements(bankId, userId, roles) } // Consumer AND User has the Role @@ -2399,20 +2475,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def userHasTheRoles: Boolean = { val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) - userHasTheRole || { - getPropsAsBoolValue("create_just_in_time_entitlements", false) && !isConsentUser(userId) && { - (hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) || - hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) && - roles.forall { role => - val addedEntitlement = Entitlement.entitlement.vend.addEntitlement( - bankId, userId, role.toString, "create_just_in_time_entitlements", - grantedByUserId = Some(userId) - ) - logger.info(s"Just in Time Entitlements: $addedEntitlement") - addedEntitlement.isDefined - } - } - } + userHasTheRole || grantJustInTimeEntitlements(bankId, userId, roles) } def consumerHasTheScopes: Boolean = diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 407672630d..670b3744ea 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -242,7 +242,10 @@ object ApiRole extends MdcLoggable{ // Operator role for registering each onboarded bank's AMQP broker coordinates // (host/port/vhost/credentials) in the per-bank publish registry. Transport // registry, not corridor-specific; Open Corridor Interface C is the first consumer. - case class CanConfigureAmqpBankBroker(requiresBankId: Boolean = false) extends ApiRole + // Named per bank and granted per bank: the coordinates decide where one bank's settlement and + // credit messages are published, so a grant for one bank must not repoint another's. It was + // declared requiresBankId = false until 2026-09-23, which made a single row cover every bank. + case class CanConfigureAmqpBankBroker(requiresBankId: Boolean = true) extends ApiRole lazy val canConfigureAmqpBankBroker = CanConfigureAmqpBankBroker() // Open Corridor: operator role for the settle-pair trigger — nets a bank pair's @@ -540,7 +543,7 @@ object ApiRole extends MdcLoggable{ case class CanGetAdapterInfo(requiresBankId: Boolean = false) extends ApiRole lazy val canGetAdapterInfo = CanGetAdapterInfo() - case class CanGetAdapterInfoAtOneBank(requiresBankId: Boolean = false) extends ApiRole + case class CanGetAdapterInfoAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetAdapterInfoAtOneBank = CanGetAdapterInfoAtOneBank() case class CanGetDatabaseInfo(requiresBankId: Boolean = false) extends ApiRole @@ -859,19 +862,26 @@ object ApiRole extends MdcLoggable{ case class CanDeleteRegulatedEntityAttribute(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteRegulatedEntityAttribute = CanDeleteRegulatedEntityAttribute() - case class CanGetCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + case class CanGetCounterpartyAttribute(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCounterpartyAttribute = CanGetCounterpartyAttribute() - case class CanGetCounterpartyAttributes(requiresBankId: Boolean = false) extends ApiRole + case class CanGetCounterpartyAttributes(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCounterpartyAttributes = CanGetCounterpartyAttributes() - case class CanCreateCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + // A Counterparty Attribute belongs to one bank's account, and adapter info is asked for one bank, + // so these Roles name a bank like every other attribute Role does (CanCreateProductAttribute, + // CanCreateAtmAttribute and the rest). They were declared requiresBankId = false, which meant a + // single Entitlement row authorised them at every bank however the endpoint was addressed. + // Existing grants are NOT migrated: an Entitlement held at the system scope stops authorising + // these endpoints, and an operator grants the Role again at the banks that need it. The release + // note of 23/09/2026 says so, and ANY_BANK_ROLE_REMOVAL_PLAN.md has the reasoning. + case class CanCreateCounterpartyAttribute(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateCounterpartyAttribute = CanCreateCounterpartyAttribute() - case class CanUpdateCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + case class CanUpdateCounterpartyAttribute(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateCounterpartyAttribute = CanUpdateCounterpartyAttribute() - case class CanDeleteCounterpartyAttribute(requiresBankId: Boolean = false) extends ApiRole + case class CanDeleteCounterpartyAttribute(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteCounterpartyAttribute = CanDeleteCounterpartyAttribute() 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 b4d762c5e5..bf6b115e75 100644 --- a/obp-api/src/main/scala/code/api/util/DynamicUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DynamicUtil.scala @@ -33,7 +33,6 @@ import code.api.{APIFailureNewStyle, JsonResponseException} import code.api.util.ErrorMessages.DynamicResourceDocMethodDependency import cats.effect.IO import code.util.Helper.MdcLoggable -import com.openbankproject.commons.model.BankId import com.openbankproject.commons.util.Functions.Memo import com.openbankproject.commons.util.{JsonUtils, ReflectUtils} import javassist.{ClassPool, LoaderClassPath} @@ -43,7 +42,6 @@ import com.openbankproject.commons.util.JsonAliases.prettyRender import org.apache.commons.lang3.StringUtils import org.graalvm.polyglot.{Context, Engine, HostAccess, PolyglotAccess} -import java.security.{AccessControlContext, AccessController, CodeSource, Permission, PermissionCollection, Permissions, Policy, PrivilegedAction, ProtectionDomain} import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.function.Consumer @@ -256,95 +254,32 @@ object DynamicUtil extends MdcLoggable{ Nil } - trait Sandbox { - @throws[Exception] - def runInSandbox[R](action: => R): R - - /** - * Run a dynamic body's IO under the same security sandbox, for native (http4s) runtime-compiled - * dynamic endpoints (Piece C). The body's SYNCHRONOUS CONSTRUCTION (forcing the by-name `io`, - * i.e. applying the compiled handler / running the user statements up to the first Future) runs - * inside the privileged context with the restricted permissions; the resulting IO is then - * evaluated by the cats-effect runtime OUTSIDE the privileged context. This mirrors the Lift - * path exactly: there `runInSandbox { process(...) }` wrapped only the synchronous construction - * plus the blocking wait, while the user's Future body (DB / network / serialization) ran on the - * EC thread outside `doPrivileged`. Running the whole IO inside `doPrivileged` instead would - * (wrongly) subject framework I/O — DB sockets, etc. — to the dynamic-code permission set. - * - * Non-local `return`: when the dynamic body is the runtime-compiled template it is a closure, - * so `return errorResponse(...)` throws a `NonLocalReturnControl` carrying the IO it should - * return (the Lift runInSandbox caught the JsonResponse equivalent). We recover that IO here so - * an early `return` in user code yields its response rather than a 500. (In PractiseEndpoint the - * body is a real method, so `return` is an ordinary return and never reaches this catch.) - */ - def runInSandboxIO[A](io: => IO[A]): IO[A] = { - def forceBodyIO(): IO[A] = + /** + * Forces a dynamic body's IO and recovers the non-local `return` a compiled closure + * uses for an early exit, so `return errorResponse(...)` in user code yields its + * response rather than a 500. (In PractiseEndpoint the body is a real method, so + * `return` is ordinary and never reaches the catch.) + * + * This is what remains of the former `Sandbox`. That wrapper also ran dynamic code + * under `AccessController.doPrivileged` with a restricted permission set, configured + * by the `dynamic_code_sandbox_enable` / `dynamic_code_sandbox_permissions` props. + * SecurityManager was removed in JDK 24 (JEP 486) and OBP now requires JVM 25, so + * `doPrivileged` is a pass-through on every runtime that can run this code: the + * sandbox could not restrict file, network or reflection access, while still costing + * a privileged wrapper on every dynamic call. It has been removed rather than left as + * a switch that implies isolation it cannot deliver. + * + * Dynamic code therefore runs with the full privileges of the OBP process. The + * controls that do work are `allow_user_generated_scala_code` (the master gate), + * `dynamic_code_obp_calls_are_restricted` (the allowlist of callable OBP methods) + * and `dynamic_code_requires_approval` (maker-checker). + */ + object DynamicCodeBody { + def force[A](io: => IO[A]): IO[A] = { + def forced(): IO[A] = try io catch { case e: scala.runtime.NonLocalReturnControl[_] => e.value.asInstanceOf[IO[A]] } - IO.defer(runInSandbox(forceBodyIO())) - } - } - - object Sandbox { - // SecurityManager was deprecated for removal in JDK 17 (JEP 411) and setSecurityManager() - // now throws UnsupportedOperationException on this runtime (JDK 25). Catch and ignore so - // the rest of the Sandbox (AccessController.doPrivileged) still compiles and runs — but - // with no SecurityManager installed, AccessController.doPrivileged is a pass-through: - // Sandbox.runInSandbox no longer actually restricts what dynamic-endpoint/connector - // code can do (file/network/reflection access are all unguarded). Log loudly so this - // silent security regression isn't invisible in production — it was previously masked - // by three DynamicUtilTest scenarios that are now `assume`-skipped for the same reason. - try { - if (System.getSecurityManager == null) { - Policy.setPolicy(new Policy() { - override def getPermissions(codeSource: CodeSource): PermissionCollection = { - for (element <- Thread.currentThread.getStackTrace) { - if ("sun.rmi.server.LoaderHandler" == element.getClassName && "loadClass" == element.getMethodName) - return new Permissions - } - super.getPermissions(codeSource) - } - - override def implies(domain: ProtectionDomain, permission: Permission) = true - }) - System.setSecurityManager(new SecurityManager) - } - } catch { - case _: UnsupportedOperationException => - logger.warn("code.api.util.DynamicUtil.Sandbox: SecurityManager is unavailable on this JVM " + - "(JEP 486, JDK 24+). Sandbox.runInSandbox / Sandbox.createSandbox will NOT enforce any " + - "permission restrictions on dynamic-endpoint / connector-builder code — file, network and " + - "reflection access are unguarded. This is expected on JDK 24+ but is a real reduction in " + - "isolation for the dynamic-code feature; do not rely on this sandbox for untrusted code on this runtime.") - } - - def createSandbox(permissionList: List[Permission]): Sandbox = { - val accessControlContext: AccessControlContext = { - val permissions = new Permissions() - permissionList.foreach(permissions.add) - val protectionDomain = new ProtectionDomain(null, permissions) - new AccessControlContext(Array(protectionDomain)) - } - - new Sandbox { - @throws[Exception] - def runInSandbox[R](action: => R): R = { - val privilegedAction: PrivilegedAction[R] = () => action - AccessController.doPrivileged(privilegedAction, accessControlContext) - } - // The former NonLocalReturnControl[JsonResponse] catch (for the Lift dynamic-code path's - // `return Full(errorJsonResponse(...))`) is gone: the only caller is runInSandboxIO, whose - // forceBodyIO already recovers a NonLocalReturnControl before it reaches here. - } - } - - private val memoSandbox = new Memo[String, Sandbox] - - /** - * this method will call create Sandbox underneath, but will have default permissions and bankId permission and cache. - */ - def sandbox(bankId: String): Sandbox = memoSandbox.memoize(bankId) { - Sandbox.createSandbox(BankId.permission(bankId) :: Validation.allowedRuntimePermissions) + IO.defer(forced()) } } @@ -396,7 +331,6 @@ object DynamicUtil extends MdcLoggable{ |import scala.concurrent.{Await, Future} |import com.openbankproject.commons.dto._ |import code.api.util.APIUtil.ResourceDoc - |import code.api.util.DynamicUtil.Sandbox |import code.api.util.NewStyle.HttpCode |import code.api.util._ |import code.api.v4_0_0.JSONFactory400 @@ -420,7 +354,7 @@ object DynamicUtil extends MdcLoggable{ object Validation { /** - * Turn the `dynamic_code_compile_validate_dependencies` props value into the Scala source + * Turn the `dynamic_code_allowed_obp_methods` props value into the Scala source * that, once compiled, yields the whitelist. * * A named function rather than an inline expression so a test can drive the real thing. @@ -439,31 +373,32 @@ object DynamicUtil extends MdcLoggable{ dependenciesString.replaceFirst("\\[", "Map[String, String](").dropRight(1) + ").mapValues(v => StringUtils.split(v, ',').map(_.trim).toSet).toMap" - 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.compileScalaCodeUnchecked(scalaCodePermissioins) - // all Permissions put at here - // Here is the Java Permission document, please extend these permissions carefully. - // https://docs.oracle.com/javase/8/docs/technotes/guides/security/spec/security-spec.doc3.html#17001 - // If you are not familiar with the permissions, we provide the clear error messages for the missing permissions in the log. - // eg1 scala test level : and have a look at the scala test for `createSandbox` method, you can see how to add permissions there too. - // eg2 api level: "OBP-40047: DynamicResourceDoc method have no enough permissions. No permission of: (\"java.io.FilePermission\" \"stop-words-en.txt\" \"write\")" - // --> you can extends following permission: new java.net.SocketPermission("ir.dcs.gla.ac.uk:80", "connect,resolve"), - // NOTE: These permissions are only checked during runtime, not the compilation period. -// val allowedRuntimePermissions = List[Permission]( -// new NetPermission("specifyStreamHandler"), -// new ReflectPermission("suppressAccessChecks"), -// new RuntimePermission("getenv.*"), -// new PropertyPermission("cglib.useCache", "read"), -// new PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"), -// new PropertyPermission("cglib.debugLocation", "read"), -// new RuntimePermission("accessDeclaredMembers"), -// new RuntimePermission("getClassLoader"), -// ) - val allowedRuntimePermissions = permissions.openOrThrowException("Can not compile the props `dynamic_code_sandbox_permissions` to permissions") - - val dependenciesString = APIUtil.getPropsValue("dynamic_code_compile_validate_dependencies", "[]").trim + // Runtime permission control was removed with the sandbox: SecurityManager is gone + // from JDK 24+ (JEP 486) and OBP requires JVM 25, so it could not be enforced. See + // DynamicUtil.DynamicCodeBody for what replaced it and which controls still work. + + /** + * Renamed 2026-09-23: dynamic_code_compile_validate_enable -> dynamic_code_obp_calls_are_restricted + * and dynamic_code_compile_validate_dependencies -> dynamic_code_allowed_obp_methods. The old + * names said "compile validate", which reads as "check that it compiles"; what they actually + * control is an allowlist of the OBP methods dynamic code may call. + * + * Both legacy names are still read, because dropping them would silently disable a restriction + * an operator had deliberately turned on. A deployment using either is warned, once, at boot. + */ + private def legacyProp(current: String, legacy: String): Box[String] = { + val legacyValue = APIUtil.getPropsValue(legacy) + if (legacyValue.isDefined && APIUtil.getPropsValue(current).isEmpty) { + logger.warn(s"Props `$legacy` is deprecated and has been renamed to `$current`. The old " + + s"name is still honoured, but rename it: support will be removed.") + } + APIUtil.getPropsValue(current) or legacyValue + } + + val dependenciesString = + legacyProp("dynamic_code_allowed_obp_methods", "dynamic_code_compile_validate_dependencies") + .openOr("[]").trim val scalaCodeDependencies = dependenciesScalaCode(dependenciesString) val dependenciesBox: Box[Map[String, Set[String]]] = DynamicUtil.compileScalaCodeUnchecked(scalaCodeDependencies) @@ -487,7 +422,6 @@ object DynamicUtil extends MdcLoggable{ // JSONFactory400.getClass.getTypeName -> "createBanksJson", // // // class methods -// classOf[Sandbox].getTypeName -> "runInSandbox", // classOf[CallContext].getTypeName -> "*", // classOf[ResourceDoc].getTypeName -> "getPathParams", // "scala.reflect.runtime.package$" -> "universe", @@ -496,7 +430,7 @@ object DynamicUtil extends MdcLoggable{ // PractiseEndpoint.getClass.getTypeName + "*" -> "*", // // ).mapValues(v => StringUtils.split(v, ',').map(_.trim).toSet) - val allowedCompilationMethods: Map[String, Set[String]] = dependenciesBox.openOrThrowException("Can not compile the props `dynamic_code_compile_validate_dependencies` to Map") + val allowedCompilationMethods: Map[String, Set[String]] = dependenciesBox.openOrThrowException("Can not compile the props `dynamic_code_allowed_obp_methods` to Map") //Do not touch this Set, try to use the `allowedPermissions` and `allowedMethods` to control the sandbox val restrictedTypes = Set( @@ -534,7 +468,9 @@ object DynamicUtil extends MdcLoggable{ } def validateDependency(obj: AnyRef): Unit = { - if(APIUtil.getPropsAsBoolValue("dynamic_code_compile_validate_enable",false)){ + val restricted = legacyProp("dynamic_code_obp_calls_are_restricted", "dynamic_code_compile_validate_enable") + .map(_.trim.equalsIgnoreCase("true")).openOr(false) + if(restricted){ val dependentMethods: List[(String, String, String)] = DynamicUtil.getDynamicCodeDependentMethods(obj.getClass) validateDependency(dependentMethods) } else{ // If false, nothing to do here. 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 2f9407be12..ae2c3fcca8 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -1234,6 +1234,7 @@ object Glossary extends MdcLoggable { | - CanCreateEntitlementAtOneBank | - CanCreateEntitlementAtAnyBank |Consent users (the principal a Consent-JWT authenticates as) never receive Just in Time Entitlements: their Roles come only from the Consent, even if the Consent carries CanCreateEntitlementAtOneBank. + |Nothing is ever granted this way in the system space either, the space whose bank id is the literal SYS. Reaching that space is meant to be a deliberate act, so a Role there is granted by hand by someone holding the system space granting Role; holding the ordinary per bank granting Role at the bank id SYS grants nothing and the request is refused with the usual missing Role error. |If create_just_in_time_entitlements is again set to false after it was true for a while, any auto granted Entitlements to roles are kept in place. |Note: In the entitlements model we set createdbyprocess=create_just_in_time_entitlements. For manual operations we set createdbyprocess=manual | 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 eec9baf1d0..7156c7b147 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 @@ -2300,7 +2300,9 @@ object Http4s600 { case req @ GET -> `prefixPath` / "management" / "dynamic-entities" / "reference-types" => EndpointHelpers.withUser(req) { (_, _) => Future { - val referenceTypeNames = code.dynamicEntity.ReferenceType.referenceTypeNames + // The catalogue of every reference type on the instance. A definition is validated against + // the types of its own space (ReferenceType.referenceTypeNames), which is the narrower list. + val referenceTypeNames = code.dynamicEntity.ReferenceType.allReferenceTypeNames val dynamicEntityNames = NewStyle.function.getDynamicEntities(None, true) .map(e => s"reference:${e.entityName}").toSet val exampleId1 = APIUtil.generateUUID() @@ -12745,14 +12747,14 @@ object Http4s600 { s"""Dry-run validation of a Dynamic Resource Doc. Send the same payload you would send to `Create Dynamic Resource Doc` and this endpoint will: | |- Parse `method_body` (URL-decoded) as Scala code and run the ToolBox compiler against it, wrapped in the same template used at runtime (request/response case classes generated from `example_request_body` / `success_response_body`). - |- Run the OBP compilation-dependency guard (when the OBP prop `dynamic_code_compile_validate_enable` is set to `true`). + |- Run the OBP call allowlist guard (when the OBP prop `dynamic_code_obp_calls_are_restricted` is set to `true`). | |Always returns HTTP 200. Inspect the `valid` field in the response: | |* `true` — the Scala compiles and all referenced OBP methods are on the allowlist. |* `false` — the response includes `error` (raw compiler / guard message), `message` (OBP error constant) and `details.error_type` — one of: | * `CompilationError` — `method_body` failed to compile. - | * `DependencyError` — compiled, but references OBP types/methods that the admin has not allowed in `dynamic_code_compile_validate_dependencies`. + | * `DependencyError` — compiled, but references OBP types/methods that the admin has not allowed in `dynamic_code_allowed_obp_methods`. | * `UnknownError` — any other unexpected exception. | |Nothing is persisted and no endpoint is served as a result of calling this. diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 97f27fd377..3b7eee529b 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -6750,7 +6750,7 @@ object Http4s700 { |`example_request_body` and `success_response_body` (they become the generated `RequestRootJsonClass` / `ResponseRootJsonClass`). | |`errors` carry the compiler's messages with `line` and `column` relative to the method body you sent (the server's wrapper lines are - |subtracted; 0 when the compiler gave no position). When the body compiles and `dynamic_code_compile_validate_enable` is on, + |subtracted; 0 when the compiler gave no position). When the body compiles and `dynamic_code_obp_calls_are_restricted` is on, |the dependency validator runs too and any forbidden call is reported in `dependency_error`. `compiles` is true only when both pass. | |Nothing is evaluated or cached, but compiling is a full scalac run, so the same rules apply as for creating: the diff --git a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala index 121e399364..5638047b11 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala @@ -190,7 +190,7 @@ trait DynamicEntityT { case (t, None, v) if t.startsWith("reference:") => val value = v.asInstanceOf[JString].s - ReferenceType.validateRefValue(t, propertyName, value, callContext) + ReferenceType.validateRefValue(bankId, t, propertyName, value, callContext) case (_, Some(DynamicEntityFieldType.string), v) if ! DynamicEntityFieldType.string.isLengthValid(v, minLength, maxLength) => @@ -372,7 +372,32 @@ object ReferenceType extends MdcLoggable { } ) - def referenceTypeNames: List[String] = { + /** + * The reference types a definition in `space` (bank_id) may declare. + * + * A `reference:` field points at a record in its own space, so only the entities of that space are + * offered: a definition at a bank may reference that bank's entities, and a definition in the + * system space may reference the system space's. The static references below are unaffected, + * because a Bank, a Customer or a Transaction is one object on the instance and has no space of + * its own. `space` is the bank id of the definition being validated, and None is the system space. + * + * Cross space links are not forbidden forever, only kept out of `reference:`; when they are wanted + * they get a type name of their own so that no existing definition changes meaning. + */ + def referenceTypeNames(space: Option[String]): List[String] = { + val dynamicRefs: List[String] = NewStyle.function.getDynamicEntities(space, false) + .map(entity => s"reference:${entity.entityName}") + + val staticRefs: List[String] = staticRefTypeToValidateFunction.keys.toList + dynamicRefs ++: staticRefs + } + + /** + * Every reference type on the instance, whatever space it belongs to. This is the catalogue the + * Get Reference Types endpoint serves, not the list a definition is validated against: use + * [[referenceTypeNames]] with the definition's own space for that. + */ + def allReferenceTypeNames: List[String] = { val dynamicRefs: List[String] = NewStyle.function.getDynamicEntities(None, true) .map(entity => s"reference:${entity.entityName}") @@ -410,7 +435,7 @@ object ReferenceType extends MdcLoggable { val reg2 = """reference:(?:[^:]+):([^&]+)&([^&]+)""".r val reg3 = """reference:(?:[^:]+):([^&]+)&([^&]+)&([^&]+)""".r val reg4 = """reference:(?:[^:]+):([^&]+)&([^&]+)&([^&]+)&([^&]+)""".r - referenceTypeNames.zipWithIndex.map { pair => + allReferenceTypeNames.zipWithIndex.map { pair => val (refTypeName, index) = pair val example = refTypeName match { case reg1(_) => exampleId1 @@ -427,21 +452,27 @@ object ReferenceType extends MdcLoggable { } } - def validateRefValue(typeName: String, propertyName: String, value: String, callContext: Option[CallContext]): Future[String] = { + /** + * Check one `reference:` value. `space` is the bank id of the entity holding the field, and None + * is the system space; the referenced record must live in that same space. Since the record id + * became unique per space rather than per instance, an id alone no longer identifies a record, so + * a check that ignored the space would accept another bank's record as if it were this one's. + */ + def validateRefValue(space: Option[String], typeName: String, propertyName: String, value: String, callContext: Option[CallContext]): Future[String] = { if(staticRefTypeToValidateFunction.contains(typeName)) { staticRefTypeToValidateFunction.get(typeName).get.apply(propertyName, value, callContext) } else { val dynamicEntityName = typeName.replace("reference:", "") val errorMsg = s"""$dynamicEntityName not found by the id value '$value', propertyName is '$propertyName'""" - logger.info(s"========== Validating reference field: propertyName='$propertyName', typeName='$typeName', dynamicEntityName='$dynamicEntityName', value='$value' ==========") - + logger.debug(s"validateRefValue says: validating propertyName='$propertyName', typeName='$typeName', dynamicEntityName='$dynamicEntityName', value='$value', space='${space.getOrElse("system")}'") + Future { - val exists = code.DynamicData.MappedDynamicDataProvider.existsById(dynamicEntityName, value) + val exists = code.DynamicData.MappedDynamicDataProvider.recordExists(space, dynamicEntityName, value) if (exists) { - logger.info(s"========== Reference validation SUCCESS: propertyName='$propertyName', dynamicEntityName='$dynamicEntityName', value='$value' ==========") + logger.debug(s"validateRefValue says: found $dynamicEntityName '$value' for propertyName='$propertyName'") "" } else { - logger.warn(s"========== Reference validation FAILED: propertyName='$propertyName', dynamicEntityName='$dynamicEntityName', value='$value' ==========") + logger.debug(s"validateRefValue says: no $dynamicEntityName '$value' in this space for propertyName='$propertyName'") errorMsg } } @@ -566,7 +597,7 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo ) val JField(entityName, metadataJson) = entityFields.head - + val namePattern = "[-_A-Za-z0-9]+".r.pattern // validate entity name checkFormat(namePattern.matcher(entityName).matches(), s"$DynamicEntityInstanceValidateFail The entity name should contains characters [-_A-Za-z0-9], but current entity name: $entityName") @@ -622,7 +653,7 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo val fieldTypeName = fieldType.asInstanceOf[JString].s checkFormat(fieldType.isInstanceOf[JString] && fieldTypeName.nonEmpty, s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'type' field should exist and be a json string") - checkFormat(allowedFieldType.contains(fieldTypeName), s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'type' field should be one of these string value: ${allowedFieldType.mkString(", ")}") + checkFormat(allowedFieldType(bankId).contains(fieldTypeName), s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'type' field should be one of these string value: ${allowedFieldType(bankId).mkString(", ")}") val fieldTypeOp: Option[DynamicEntityFieldType] = DynamicEntityFieldType.withNameOption(fieldTypeName) @@ -652,7 +683,7 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo val Some(dEntityFieldType: DynamicEntityFieldType) = fieldTypeOp checkFormat(dEntityFieldType.isJValueValid(fieldExample), s"$DynamicEntityInstanceValidateFail The value of $fieldName's 'example' is wrong, ${dEntityFieldType.wrongTypeMsg}") - } else if(ReferenceType.referenceTypeNames.contains(fieldTypeName)) { + } else if(ReferenceType.referenceTypeNames(bankId).contains(fieldTypeName)) { checkFormat(fieldExample.isInstanceOf[JString], s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'example' field should be type ${DynamicEntityFieldType.string}") checkFormat(ReferenceType.isLegalReferenceValue(fieldTypeName, fieldExample.asInstanceOf[JString].s), s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'example' is illegal format.") } else { @@ -720,8 +751,8 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo // `reference` is an internal query-layer type (see DynamicEntityFieldType.reference), never declared // bare by callers — they declare `reference:`, which ReferenceType.referenceTypeNames supplies. - private def allowedFieldType: List[String] = - DynamicEntityFieldType.values.filterNot(_ == DynamicEntityFieldType.reference).map(_.toString) ++: ReferenceType.referenceTypeNames + private def allowedFieldType(space: Option[String]): List[String] = + DynamicEntityFieldType.values.filterNot(_ == DynamicEntityFieldType.reference).map(_.toString) ++: ReferenceType.referenceTypeNames(space) } /** @@ -739,21 +770,15 @@ case class DynamicEntityIntTypeExample(`type`: DynamicEntityFieldType, example: trait DynamicEntityProvider { def getById(bankId: Option[String], dynamicEntityId: String): Box[DynamicEntityT] - //Note, we use entity name to create the roles, and bank level and system level can not be mixed, - // so --> here can not use bankId as parameters: + //Note, we use entity name to create the roles, and bank level and system level can not be mixed, + // so --> here can not use bankId as parameters: def getByEntityName(bankId: Option[String], entityName: String): Box[DynamicEntityT] def getDynamicEntities(bankId: Option[String], returnBothBankAndSystemLevel: Boolean): List[DynamicEntityT] - + def getDynamicEntitiesByUserId(userId: String): List[DynamicEntity] def createOrUpdate(dynamicEntity: DynamicEntityT): Box[DynamicEntityT] def delete(dynamicEntity: DynamicEntityT):Box[Boolean] } - - - - - - diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index ac21e03d9d..7b8eef8294 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -135,15 +135,22 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm } // Separate method for reference validation - only checks ID and entity name exist - def existsById(entityName: String, id: String): Boolean = { - println(s"========== Reference validation: checking if DynamicDataId='$id' exists for DynamicEntityName='$entityName' ==========") - val exists = DynamicData.count( + /** + * Does `entityName` hold a record with this id, in this space? + * + * Used to check a `reference:` field. The space matters because a record id is unique within one + * space and one entity, not across the instance: two banks may each hold a record whose natural + * key is the country code DE, and so may the system space. Asking without the space would answer + * a different question, namely whether such a record exists anywhere, and a reference would then + * resolve to another bank's record. `bankId` is the space, and None is the system space, stored + * as Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. + */ + def recordExists(bankId: Option[String], entityName: String, id: String): Boolean = + DynamicData.count( + By(DynamicData.BankId, bankId.getOrElse(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.DynamicDataId, id) ) > 0 - println(s"========== Reference validation result: exists=$exists ==========") - exists - } override def get(bankId: Option[String], entityName: String, id: String, callerUserId: Option[String], isPersonalEntity: Boolean): Box[DynamicDataT] = { val userId = ownerOf(callerUserId) diff --git a/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala new file mode 100644 index 0000000000..2d123ea23f --- /dev/null +++ b/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala @@ -0,0 +1,205 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.sweep + +import code.api.util.ApiRole +import code.setup.ServerSetupWithTestData +import org.scalatest.Tag + +/** + * Two guards that stop the "any bank" problem growing while it is being worked through. + * + * A Role declared `requiresBankId = false` is read at the empty bank id whatever bank was asked + * about (`APIUtil.hasEntitlement`), so a single Entitlement row authorises the action at every bank, + * including banks created later. That is right for a Role whose subject is the instance, such as + * CanReadMetrics, and wrong for a Role whose subject belongs to one bank. The long term plan for + * undoing the second kind is ANY_BANK_ROLE_REMOVAL_PLAN.md; this suite is what keeps the list + * finite while that happens. + * + * Each guard carries an allowlist of what exists today. **An allowlist only ever shrinks.** Adding + * a line to one means adding a new Role or endpoint that can act on every bank at once, which is + * the thing being removed; removing a line is what finishing a piece of the plan looks like. If a + * guard fails, the fix is almost always the code, not the list. + */ +class AnyBankScopeSweepTest extends ServerSetupWithTestData { + + object AnyBankScope extends Tag("AnyBankScope") + + // --------------------------------------------------------------------------------------------- + // Guard 1 — Roles whose name says they reach every bank + // --------------------------------------------------------------------------------------------- + + /** A Role name that says out loud that it reaches more than one bank. */ + private val anyBankNamePattern = """(AtAnyBank|AnyBank|AtAllBanks|AllBanks)""".r + + /** + * The Roles that say "any bank" in their name and are declared `requiresBankId = false` today. + * Every line is a Role that ANY_BANK_ROLE_REMOVAL_PLAN.md exists to retire. + */ + private val rolesThatReachEveryBank: Set[String] = Set( + "CanAddUserToGroupAtAllBanks", + "CanCreateAccountAccessRequestAtAnyBank", + "CanCreateAnyBankLevelDynamicEntity", + "CanCreateAtmAtAnyBank", + "CanCreateAtmAttributeAtAnyBank", + "CanCreateBranchAtAnyBank", + "CanCreateCounterpartyAtAnyBank", + "CanCreateCustomerAtAnyBank", + "CanCreateCustomerAttributeAtAnyBank", + "CanCreateEntitlementAtAnyBank", + "CanCreateFxRateAtAnyBank", + "CanCreateGroupAtAllBanks", + "CanCreateProductAtAnyBank", + "CanCreateScopeAtAnyBank", + "CanCreateUserCustomerLinkAtAnyBank", + "CanDeleteAtmAtAnyBank", + "CanDeleteAtmAttributeAtAnyBank", + "CanDeleteBranchAtAnyBank", + "CanDeleteCounterpartyAtAnyBank", + "CanDeleteCustomerAttributeAtAnyBank", + "CanDeleteEntitlementAtAnyBank", + "CanDeleteEntitlementRequestsAtAnyBank", + "CanDeleteGroupAtAllBanks", + "CanDeleteScopeAtAnyBank", + "CanDeleteUserCustomerLinkAtAnyBank", + "CanGetAccountAccessRequestsAtAnyBank", + "CanGetAccountsHeldAtAnyBank", + "CanGetAccountsMinimalForCustomerAtAnyBank", + "CanGetAnyBankLevelDynamicEntities", + "CanGetAtmAttributeAtAnyBank", + "CanGetConsentsAtAnyBank", + "CanGetCorrelatedUsersInfoAtAnyBank", + "CanGetCounterpartiesAtAnyBank", + "CanGetCounterpartyAtAnyBank", + "CanGetCustomerAttributeAtAnyBank", + "CanGetCustomerAttributesAtAnyBank", + "CanGetCustomersAtAllBanks", + "CanGetCustomersMinimalAtAllBanks", + "CanGetDoubleEntryTransactionAtAnyBank", + "CanGetEntitlementRequestsAtAnyBank", + "CanGetEntitlementsForAnyBank", + "CanGetEntitlementsForAnyUserAtAnyBank", + "CanGetGroupsAtAllBanks", + "CanGetRolesWithEntitlementCountsAtAllBanks", + "CanGetTransactionRequestAtAnyBank", + "CanGetUserCustomerLinkAtAnyBank", + "CanGetUserGroupMembershipsAtAllBanks", + "CanGetViewPermissionsAtAllBanks", + "CanRemoveUserFromGroupAtAllBanks", + "CanUpdateAccountAccessRequestAtAnyBank", + "CanUpdateAgentStatusAtAnyBank", + "CanUpdateAtmAtAnyBank", + "CanUpdateAtmAttributeAtAnyBank", + "CanUpdateConsentAccountAccessAtAnyBank", + "CanUpdateConsentStatusAtAnyBank", + "CanUpdateConsentUserAtAnyBank", + "CanUpdateCustomerAttributeAtAnyBank", + "CanUpdateCustomerCreditRatingAndSourceAtAnyBank", + "CanUpdateGroupAtAllBanks", + "CanUpdateProductTagsAtAnyBank", + "CanUpdateTransactionRequestStatusAtAnyBank", + "CanUseAccountFirehoseAtAnyBank", + "CanUseCustomerFirehoseAtAnyBank" + ) + + // --------------------------------------------------------------------------------------------- + // Guard 2 — endpoints whose URL names a bank while their Roles ignore it + // --------------------------------------------------------------------------------------------- + + /** + * Endpoints whose URL carries BANK_ID while every Role they declare is system scoped, so the bank + * in the path does not narrow the permission at all: holding the Role once authorises the call at + * every bank. These are any-bank Roles without the name, which is why they are easy to miss. + * + * Keyed by "VERB requestUrl" as the catalog reports it. + */ + private val endpointsWhoseBankIdDoesNotNarrow: Set[String] = Set( + "GET /banks/BANK_ID/accounts/ACCOUNT_ID/views/TARGET_VIEW_ID/users/TARGET_USER_ID/account-access-trace", + "GET /banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/users-with-access", + "GET /management/banks/BANK_ID/dynamic-message-docs", + "GET /management/system/integrity/banks/BANK_ID/account-currency-check", + "GET /management/system/integrity/banks/BANK_ID/orphaned-account-check", + "POST /banks/BANK_ID/utility-payments/UTILITY_TRANSACTION_REQUEST_ID/vend-result", + "POST /management/banks/BANK_ID/accounts/ACCOUNT_ID/views", + "PUT /management/banks/BANK_ID/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID" + ) + + private def rolesNamingAnyBank: List[ApiRole] = + ApiRole.availableRoles + // A Dynamic Entity Role is named after an entity an operator created, not written here, so it + // is not something this suite can hold anyone to. + .filterNot(_.contains("_")) + .filter(name => anyBankNamePattern.findFirstIn(name).isDefined) + .map(ApiRole.valueOf) + + feature("The set of Roles that can act on every bank does not grow") { + scenario("Every Role naming any bank is already on the list being retired", AnyBankScope) { + val reachEveryBank = rolesNamingAnyBank.filterNot(_.requiresBankId).map(_.toString).toSet + + val added = (reachEveryBank -- rolesThatReachEveryBank).toList.sorted + withClue( + "These Roles say 'any bank' in their name and are declared requiresBankId = false, which " + + "means one Entitlement row authorises them at every bank, now and in the future. See " + + "ANY_BANK_ROLE_REMOVAL_PLAN.md: the answer is a per bank Role, not a line in this list.\n" + + added.mkString("\n") + "\n") { + added shouldBe empty + } + + val retired = (rolesThatReachEveryBank -- reachEveryBank).toList.sorted + withClue( + "These Roles are on the list but no longer reach every bank, so the list is stale. " + + "Delete these lines - that is what finishing a retirement looks like.\n" + + retired.mkString("\n") + "\n") { + retired shouldBe empty + } + } + } + + feature("The set of endpoints whose BANK_ID does not narrow the permission does not grow") { + scenario("Every endpoint naming a bank either scopes a Role to it or is already on the list", AnyBankScope) { + val offenders = EndpointCatalog.all.filter { doc => + doc.requestUrl.contains("BANK_ID") && + doc.roles.exists(roles => roles.nonEmpty && roles.forall(!_.requiresBankId)) + }.map(doc => s"${doc.requestVerb} ${doc.requestUrl}").toSet + + val added = (offenders -- endpointsWhoseBankIdDoesNotNarrow).toList.sorted + withClue( + "These endpoints name a bank in the URL, but every Role they declare is system scoped, so " + + "holding that Role once authorises the call at every bank. Declare a bank scoped Role " + + "instead, or alongside.\n" + added.mkString("\n") + "\n") { + added shouldBe empty + } + + val fixed = (endpointsWhoseBankIdDoesNotNarrow -- offenders).toList.sorted + withClue( + "These endpoints are on the list but now scope a Role to the bank in their URL, so the " + + "list is stale. Delete these lines.\n" + fixed.mkString("\n") + "\n") { + fixed shouldBe empty + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v3_0_0/GetAdapterInfoTest.scala b/obp-api/src/test/scala/code/api/v3_0_0/GetAdapterInfoTest.scala index 51fcdec477..029832ab62 100644 --- a/obp-api/src/test/scala/code/api/v3_0_0/GetAdapterInfoTest.scala +++ b/obp-api/src/test/scala/code/api/v3_0_0/GetAdapterInfoTest.scala @@ -68,8 +68,19 @@ class GetAdapterInfoTest extends V300ServerSetup with DefaultUsers { And("error should be " + UserHasMissingRoles + canGetAdapterInfoAtOneBank) response310.body.extract[ErrorMessage].message contains (UserHasMissingRoles + canGetAdapterInfoAtOneBank) shouldBe (true) } + scenario("A grant at another bank does not open this one", ApiEndpoint, VersionOfApi) { + // The Role is named AtOneBank and the handler checks it at the bank in the URL, so a grant + // made at some other bank must authorise nothing here. + val entitlement = Entitlement.entitlement.vend.addEntitlement( + testBankId2.value, resourceUser1.userId, canGetAdapterInfoAtOneBank.toString) + When("We make a request for a bank the Role was not granted at") + val response310 = makeGetRequest((v3_0Request / "banks" / testBankId1.value / "adapter").GET <@ (user1)) + Then("We should get a 403") + response310.code should equal(403) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } scenario("We will try to get adapter info", ApiEndpoint, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetAdapterInfoAtOneBank.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, canGetAdapterInfoAtOneBank.toString) When("We make a request v3.1.0") val request310 = (v3_0Request / "banks"/testBankId1.value/ "adapter").GET <@ (user1) val response310 = makeGetRequest(request310) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala index c2df15ea8b..543f58b2a8 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala @@ -249,7 +249,7 @@ class DynamicResourceDocTest extends V400ServerSetup { // End-to-end exercise of the NATIVE runtime-compiled dynamic-endpoint dispatch (Piece C): // Http4sDynamicEndpoint.pieceC -> DynamicEndpoints.findEndpoint -> ResourceDoc.authCheckIO -> - // the compiled OBPEndpointIO handler -> Sandbox.runInSandboxIO -> OBPReturnType => IO[Response] implicit. + // the compiled OBPEndpointIO handler -> DynamicCodeBody.force -> OBPReturnType => IO[Response] implicit. // The metadata-CRUD scenarios above only prove the doc/template compiles; these prove it RUNS. feature("Native execution of runtime-compiled dynamic endpoints (Piece C)") { diff --git a/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala index f6085e4c52..f0d782746c 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/JustInTimeEntitlementsTest.scala @@ -27,7 +27,11 @@ TESOBE (http://www.tesobe.com/) package code.api.v5_1_0 +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID +import code.api.util.APIUtil import code.api.util.APIUtil.OAuth._ +import code.api.util.APIUtil.UserOnly +import code.api.util.ApiRole import code.api.util.ApiRole.{CanCreateEntitlementAtAnyBank, CanCreateEntitlementAtOneBank, CanGetAnyUser, CanGetMetricsAtOneBank} import code.api.util.ErrorMessages.UserHasMissingRoles import code.api.v2_1_0.MetricsJson @@ -127,4 +131,136 @@ class JustInTimeEntitlementsTest extends V510ServerSetup with DefaultUsers { } } + + feature(s"Just In Time Entitlements are never granted in the system space - $VersionOfApi") { + // The system space is the space whose bank id is the literal SYS. A Role there is granted by + // hand by someone holding the system space granting Role, never automatically, so that reaching + // the instance wide space stays a deliberate act. Held at the function rather than over HTTP + // because no endpoint resolves SYS as a bank id yet; see DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md. + scenario("The per bank granting Role held at SYS grants nothing there", VersionOfApi) { + setPropsValues("create_just_in_time_entitlements" -> "true") + val wantedRole = ApiRole.canGetMetricsAtOneBank + Entitlement.entitlement.vend.addEntitlement( + DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateEntitlementAtOneBank.toString) + + When("access control is asked about a Role in the system space") + val allowedInSystemSpace = APIUtil.handleAccessControlWithAuthMode( + DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "", List(wantedRole), UserOnly) + + Then("it refuses") + allowedInSystemSpace should equal(false) + + And("no Entitlement was written in the system space") + Entitlement.entitlement.vend.getEntitlement( + DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, wantedRole.toString).isDefined should equal(false) + } + + scenario("The same caller at an ordinary bank is still granted just in time", VersionOfApi) { + setPropsValues("create_just_in_time_entitlements" -> "true") + val wantedRole = ApiRole.canGetMetricsAtOneBank + val bankId = testBankId1.value + Entitlement.entitlement.vend.addEntitlement( + bankId, resourceUser2.userId, CanCreateEntitlementAtOneBank.toString) + + When("access control is asked about the same Role at a real bank") + val allowedAtBank = APIUtil.handleAccessControlWithAuthMode( + bankId, resourceUser2.userId, "", List(wantedRole), UserOnly) + + Then("it allows, which is the behaviour the system space rule must not have broken") + allowedAtBank should equal(true) + + And("the Entitlement was written at that bank") + Entitlement.entitlement.vend.getEntitlement( + bankId, resourceUser2.userId, wantedRole.toString).isDefined should equal(true) + } + } + + + feature(s"Just In Time Entitlements only grant what the caller could have granted by hand - $VersionOfApi") { + // Granting an Entitlement by hand goes through Add Entitlement, which refuses a Role whose + // scope does not match the bank id it is given: a Role declared with requiresBankId = false + // lives at the system scope and cannot be attached to a bank, and the caller needs a granting + // Role that reaches that scope. Just in Time granting is documented as doing automatically what + // that manual process would have allowed anyway, so it has to obey the same two rules. These + // scenarios sit at the function rather than over HTTP so that the scope of the row that gets + // written is visible, which is the part a response code cannot show. + + scenario("A system scoped Role is not granted from per bank granting rights", VersionOfApi) { + setPropsValues("create_just_in_time_entitlements" -> "true") + val bankId = testBankId1.value + val systemScopedRole = ApiRole.canSeeAccountAccessForAnyUser + systemScopedRole.requiresBankId should equal(false) + + Given("a caller who may grant Entitlements at one bank and nowhere else") + Entitlement.entitlement.vend.addEntitlement( + bankId, resourceUser3.userId, CanCreateEntitlementAtOneBank.toString) + withClue("the caller must really hold the granting Role, or this scenario proves nothing: ") { + APIUtil.hasEntitlement(bankId, resourceUser3.userId, ApiRole.canCreateEntitlementAtOneBank) should equal(true) + } + + When("access control is asked about a system scoped Role on an endpoint that carries that bank id") + val allowed = APIUtil.handleAccessControlWithAuthMode( + bankId, resourceUser3.userId, "", List(systemScopedRole), UserOnly) + + Then("it refuses, because Add Entitlement would have refused the same grant") + allowed should equal(false) + + And("no Entitlement was written, at either scope") + Entitlement.entitlement.vend.getEntitlement( + bankId, resourceUser3.userId, systemScopedRole.toString).isDefined should equal(false) + Entitlement.entitlement.vend.getEntitlement( + "", resourceUser3.userId, systemScopedRole.toString).isDefined should equal(false) + } + + scenario("A system scoped Role granted to an any bank granter is written at the system scope", VersionOfApi) { + setPropsValues("create_just_in_time_entitlements" -> "true") + val bankId = testBankId1.value + val systemScopedRole = ApiRole.canSeeAccountAccessForAnyUser + + Given("a caller who may grant Entitlements anywhere") + Entitlement.entitlement.vend.addEntitlement( + "", resourceUser1.userId, CanCreateEntitlementAtAnyBank.toString) + + When("access control is asked about a system scoped Role on an endpoint that carries a bank id") + val allowed = APIUtil.handleAccessControlWithAuthMode( + bankId, resourceUser1.userId, "", List(systemScopedRole), UserOnly) + + Then("it allows") + allowed should equal(true) + + And("the Entitlement was written at the system scope, which is the only scope this Role is ever read at") + Entitlement.entitlement.vend.getEntitlement( + "", resourceUser1.userId, systemScopedRole.toString).isDefined should equal(true) + + And("nothing was written at the bank, where no check would ever have read it") + Entitlement.entitlement.vend.getEntitlement( + bankId, resourceUser1.userId, systemScopedRole.toString).isDefined should equal(false) + } + + scenario("The granting Roles themselves are never granted this way", VersionOfApi) { + setPropsValues("create_just_in_time_entitlements" -> "true") + val bankId = testBankId1.value + + Given("a caller who may grant Entitlements at one bank") + Entitlement.entitlement.vend.addEntitlement( + bankId, resourceUser3.userId, CanCreateEntitlementAtOneBank.toString) + withClue("the caller must really hold the granting Role, or this scenario proves nothing: ") { + APIUtil.hasEntitlement(bankId, resourceUser3.userId, ApiRole.canCreateEntitlementAtOneBank) should equal(true) + } + + When("access control is asked about the Role that grants Entitlements everywhere") + val allowed = APIUtil.handleAccessControlWithAuthMode( + bankId, resourceUser3.userId, "", List(ApiRole.canCreateEntitlementAtAnyBank), UserOnly) + + Then("it refuses: the granting Roles are excluded from this automation, as the Glossary and the props template both say") + allowed should equal(false) + + And("no Entitlement was written, at either scope") + Entitlement.entitlement.vend.getEntitlement( + bankId, resourceUser3.userId, CanCreateEntitlementAtAnyBank.toString).isDefined should equal(false) + Entitlement.entitlement.vend.getEntitlement( + "", resourceUser3.userId, CanCreateEntitlementAtAnyBank.toString).isDefined should equal(false) + } + } + } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala index 66117cf280..57be013044 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala @@ -62,7 +62,7 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { } def createMockAttribute(counterpartyId: String): String = { - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCounterpartyAttribute.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) Entitlement.entitlement.vend.deleteEntitlement(entitlement) @@ -86,7 +86,7 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { } scenario("201 Success + Field Echo", Create, VersionOfApi) { - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCounterpartyAttribute.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) response.code should equal(201) @@ -97,9 +97,22 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { Entitlement.entitlement.vend.deleteEntitlement(entitlement) } + scenario("403 Forbidden when the Role was granted at a different bank", Create, VersionOfApi) { + // A Counterparty Attribute belongs to one bank's account, so the Role that writes one belongs + // to that bank too. Granting it at some other bank must authorise nothing here. + val otherBankId = testBankId2.value + val entitlement = Entitlement.entitlement.vend.addEntitlement( + otherBankId, resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 + val response = makePostRequest(request, write(counterpartyAttributeRequestJsonV600)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ErrorMessages.UserHasMissingRoles + CanCreateCounterpartyAttribute) + Entitlement.entitlement.vend.deleteEntitlement(entitlement) + } + scenario("400 Invalid Type", Create, VersionOfApi) { val badJson = counterpartyAttributeRequestJsonV600.copy(attribute_type = "UNSUPPORTED") - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateCounterpartyAttribute.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCounterpartyAttribute.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").POST <@ user1 val response = makePostRequest(request, write(badJson)) response.code should equal(400) @@ -126,7 +139,7 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { lazy val counterpartyId = createMockCounterparty() lazy val attributeId = createMockAttribute(counterpartyId) - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateCounterpartyAttribute.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanUpdateCounterpartyAttribute.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).PUT <@ user1 val response = makePutRequest(request, write(counterpartyAttributeRequestJsonV600)) response.code should equal(200) @@ -153,7 +166,7 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { lazy val counterpartyId = createMockCounterparty() lazy val attributeId = createMockAttribute(counterpartyId) - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteCounterpartyAttribute.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteCounterpartyAttribute.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).DELETE <@ user1 val response = makeDeleteRequest(request) response.code should equal(204) @@ -178,7 +191,7 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { scenario("200 Success", GetAll, VersionOfApi) { lazy val counterpartyId = createMockCounterparty() - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCounterpartyAttributes.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetCounterpartyAttributes.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").GET <@ user1 val response = makeGetRequest(request) response.code should equal(200) @@ -204,7 +217,7 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers { scenario("200 Success", GetOne, VersionOfApi) { lazy val counterpartyId = createMockCounterparty() lazy val attributeId = createMockAttribute(counterpartyId) - val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCounterpartyAttribute.toString) + val entitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetCounterpartyAttribute.toString) val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).GET <@ user1 val response = makeGetRequest(request) response.code should equal(200) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityReferenceSpaceTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityReferenceSpaceTest.scala new file mode 100644 index 0000000000..8ca3525fa1 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityReferenceSpaceTest.scala @@ -0,0 +1,157 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v6_0_0 + +import code.DynamicData.DynamicDataProvider +import code.dynamicEntity.{DynamicEntityCommons, DynamicEntityProvider, DynamicEntityT} +import com.openbankproject.commons.util.ApiVersion +import com.openbankproject.commons.util.JsonAliases.parse +import org.json4s.JsonAST.{JField, JObject, JString, JValue} +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * A `reference:` field points at a record in its own space, and nowhere else. + * + * A space is the bank a Dynamic Entity belongs to; the instance wide one is the system space. Two + * spaces may each hold an entity of the same name, and since the record id became unique per space + * rather than per instance (`DynamicData.dbIndexes`), they may each hold a record of the same id as + * well. Validation that ignores the space therefore answers a question nobody asked: it says a + * reference is good because *somewhere* on the instance a record with that id exists, which may be + * another bank's. The joins have always been per space (`Http4sDynamicEntity.childJoinInfo`), so + * this is validation catching up with them rather than a new restriction. + * + * Cross space links are not being forbidden forever, only kept out of `reference:`. When they are + * wanted they get a name of their own, such as `cross-space-ref`, so that the meaning of an + * existing definition never changes underneath the data. See DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md. + */ +class DynamicEntityReferenceSpaceTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + + private val owner = "reference-space-test-owner" + private val suffix = java.util.UUID.randomUUID().toString.take(8) + private val parentEntity = s"parent_$suffix" + private val childEntity = s"child_$suffix" + private lazy val otherSpace = Some(testBankId1.value) + + /** The definition JSON in the shape the validating constructor expects. */ + private def definitionJson(entity: String, propertiesJson: String): JObject = parse( + s"""{ + | "$entity": { + | "description": "An entity used by DynamicEntityReferenceSpaceTest.", + | "required": [], + | "properties": $propertiesJson + | } + |}""".stripMargin).asInstanceOf[JObject] + + private val plainProperties = + """{ "name": { "type": "string", "example": "Alice", "description": "a name" } }""" + + private def referenceProperties(target: String) = + s"""{ "parent_ref": { "type": "reference:$target", "example": "00000000-0000-0000-0000-000000000000", "description": "the parent" } }""" + + /** Register a definition without going through the validating constructor. */ + private def register(entity: String, propertiesJson: String, space: Option[String]): DynamicEntityT = + DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate( + DynamicEntityCommons( + entityName = entity, + metadataJson = s"""{"$entity":{"description":"d","required":[],"properties":$propertiesJson}}""", + dynamicEntityId = None, + userId = owner, + bankId = space, + hasPersonalEntity = false, + hasCommunityAccess = true + ) + ).openOrThrowException(s"could not register $entity") + + /** Save a record in one space and return its id. */ + private def saveRecord(entity: String, space: Option[String]): String = { + val id = java.util.UUID.randomUUID().toString + val body = JObject(List( + JField(s"${entity}_id", JString(id)), + JField("name", JString("Alice")) + )) + DynamicDataProvider.connectorMethodProvider.vend + .save(space, entity, body, Some(owner), false) + .openOrThrowException(s"could not save a $entity record") + id + } + + private def validationErrorOf(entity: DynamicEntityT, referenceValue: String): Option[String] = { + val body = JObject(List( + JField(s"${childEntity}_id", JString(java.util.UUID.randomUUID().toString)), + JField("parent_ref", JString(referenceValue)) + )) + Await.result(entity.validateEntityJson(body, None), 30.seconds) + } + + feature(s"A reference points inside its own space - $VersionOfApi") { + + scenario("A definition cannot declare a reference to an entity in another space", VersionOfApi) { + Given(s"$parentEntity exists in the system space only") + register(parentEntity, plainProperties, None) + + When(s"a definition in a bank space declares reference:$parentEntity") + val thrown = intercept[IllegalArgumentException] { + DynamicEntityCommons( + definitionJson(childEntity, referenceProperties(parentEntity)), + None, owner, otherSpace) + } + + Then("it is refused, because that entity is not in this space") + withClue(s"the message should name the offending type. Message was: ${thrown.getMessage}\n") { + thrown.getMessage should include("parent_ref") + } + } + + scenario("A record's reference cannot resolve to a record in another space", VersionOfApi) { + Given(s"$parentEntity exists in the system space and in a bank space, each holding one record") + register(parentEntity, plainProperties, None) + register(parentEntity, plainProperties, otherSpace) + val idInTheSystemSpace = saveRecord(parentEntity, None) + val idInTheBankSpace = saveRecord(parentEntity, otherSpace) + + And(s"$childEntity lives in the system space and references $parentEntity") + val child = register(childEntity, referenceProperties(parentEntity), None) + + When("a record references the parent in its own space") + Then("it is accepted") + validationErrorOf(child, idInTheSystemSpace) should equal(None) + + When("a record references a parent record that lives in the other space") + val error = validationErrorOf(child, idInTheBankSpace) + + Then("it is refused, although a record with that id does exist somewhere on the instance") + withClue("a reference must not resolve across spaces: ") { + error.isDefined should equal(true) + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 0874f2d517..ad2f80375a 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -3080,8 +3080,19 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { messageOf(json) should include("CanConfigureAmqpBankBroker") } + scenario("Return 403 when CanConfigureAmqpBankBroker was granted at another bank", Http4s700RoutesTag) { + // The coordinates registered here decide where OBP publishes one bank's settlement and credit + // messages, so the Role that writes them belongs to that bank. A grant at another bank must + // not reach this one. + addEntitlement(testBankId2.value, resourceUser1.userId, canConfigureAmqpBankBroker.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", brokerPath(testBankId1.value), brokerBody(), headers) + statusCode shouldBe 403 + messageOf(json) should include("CanConfigureAmqpBankBroker") + } + scenario("Broker registry CRUD round-trip; password is never echoed", Http4s700RoutesTag) { - addEntitlement("", resourceUser1.userId, canConfigureAmqpBankBroker.toString) + addEntitlement(testBankId1.value, resourceUser1.userId, canConfigureAmqpBankBroker.toString) val headers = Map("DirectLogin" -> s"token=${token1.value}") When("DELETE clears any previous registration (idempotent)") diff --git a/obp-api/src/test/scala/code/util/DynamicUtilTest.scala b/obp-api/src/test/scala/code/util/DynamicUtilTest.scala index c9be8d2452..e8ce9fdd0b 100644 --- a/obp-api/src/test/scala/code/util/DynamicUtilTest.scala +++ b/obp-api/src/test/scala/code/util/DynamicUtilTest.scala @@ -28,7 +28,6 @@ TESOBE (http://www.tesobe.com/) package code.util import org.json4s._ -import code.api.util.DynamicUtil.Sandbox import code.api.util._ import code.setup.PropsReset import com.openbankproject.commons.model.BankId @@ -38,7 +37,6 @@ import com.openbankproject.commons.util.json import org.scalatest.{FeatureSpec, FlatSpec, GivenWhenThen, Matchers, Tag} import java.io.File -import java.security.{AccessControlException} import scala.collection.immutable.List import scala.io.Source @@ -48,36 +46,7 @@ class DynamicUtilTest extends FlatSpec with Matchers { private val securityManagerUnavailable = "SecurityManager enforcement is not available on JDK 17+ (JEP 411); skip on JDK 21" - /** - * Skip the sandbox checks when no SecurityManager can enforce them -- but let CI refuse the skip. - * - * `assume` cancels, and a cancelled check is indistinguishable from a passing one in every - * report anybody reads: this suite has shown "canceled 0" while three of its scenarios never - * ran, because ScalaTest counts a cancellation separately from a failure and the summary line - * people look at is the failure count. Since JDK 17 removed SecurityManager enforcement - * (JEP 411, completed by JEP 486), DynamicUtil.Sandbox is a no-op on any modern JDK and these - * three have been skipping on every run, everywhere, for as long as the build has been on 21+. - * - * That is a real gap, not a formality: the sandbox is what stops runtime-compiled endpoint code - * from reading the filesystem or opening sockets, and nothing else covers it. - * - * OBP_TEST_SANDBOX_REQUIRED=true turns the cancellation into a failure, the same lever - * RedisTestTarget gives the Redis-dependent checks. Set it wherever a JDK that can still - * enforce is available; leave it unset and the skip stands, but now it is a decision somebody - * made rather than a silence. - */ - private def requireSecurityManager(): Unit = - if (System.getSecurityManager == null) { - val required = sys.env.get("OBP_TEST_SANDBOX_REQUIRED").exists(_.trim.equalsIgnoreCase("true")) - if (required) - fail("OBP_TEST_SANDBOX_REQUIRED=true but no SecurityManager is installed, so the sandbox " + - "checks cannot run. They are the only cover for what runtime-compiled endpoint code " + - "is allowed to touch -- run them on a JDK that still enforces, or unset the variable " + - "to go back to skipping.") - else cancel(securityManagerUnavailable) - } - - implicit val formats = code.api.util.CustomJsonFormats.formats +implicit val formats = code.api.util.CustomJsonFormats.formats "DynamicUtil.compileScalaCode method" should "return correct function" taggedAs DynamicUtilsTag in { @@ -98,7 +67,6 @@ class DynamicUtilTest extends FlatSpec with Matchers { | ) """.stripMargin) val permissionList = permissions.openOrThrowException("Can not compile the string to permissions") - Sandbox.createSandbox(permissionList) permissionList.toString contains ("""java.net.NetPermission""") shouldBe (true) val permissionString = @@ -112,7 +80,6 @@ class DynamicUtilTest extends FlatSpec with Matchers { val permissions2:Box[List[java.security.Permission]] = DynamicUtil.compileScalaCode(scalaCode) val permissionList2 = permissions2.openOrThrowException("Can not compile the string to permissions") - Sandbox.createSandbox(permissionList2) permissionList2.toString contains ("""java.net.NetPermission""") shouldBe (true) @@ -130,7 +97,7 @@ class DynamicUtilTest extends FlatSpec with Matchers { | JSONFactory400.getClass.getTypeName -> "createBanksJson", | | // class methods - | classOf[Sandbox].getTypeName -> "runInSandbox", + | | classOf[CallContext].getTypeName -> "*", | classOf[ResourceDoc].getTypeName -> "getPathParams", | "scala.reflect.runtime.package$" -> "universe", @@ -142,7 +109,7 @@ class DynamicUtilTest extends FlatSpec with Matchers { val dependencies = dependenciesBox.openOrThrowException("Can not compile the string to Map") dependencies.toString contains ("code.api.util.NewStyle") shouldBe (true) - val dependenciesString = """[NewStyle.function.getClass.getTypeName -> "*",CompiledObjects.getClass.getTypeName -> "sandbox",HttpCode.getClass.getTypeName -> "200",DynamicCompileEndpoint.getClass.getTypeName -> "getPathParams, scalaFutureToBoxedJsonResponse",APIUtil.getClass.getTypeName -> "errorJsonResponse, errorJsonResponse$default$1, errorJsonResponse$default$2, errorJsonResponse$default$3, errorJsonResponse$default$4, scalaFutureToLaFuture, futureToBoxedResponse",ErrorMessages.getClass.getTypeName -> "*",ExecutionContext.Implicits.getClass.getTypeName -> "global",JSONFactory400.getClass.getTypeName -> "createBanksJson",classOf[Sandbox].getTypeName -> "runInSandbox",classOf[CallContext].getTypeName -> "*",classOf[ResourceDoc].getTypeName -> "getPathParams","scala.reflect.runtime.package$" -> "universe",PractiseEndpoint.getClass.getTypeName + "*" -> "*"]""".stripMargin + val dependenciesString = """[NewStyle.function.getClass.getTypeName -> "*",CompiledObjects.getClass.getTypeName -> "sandbox",HttpCode.getClass.getTypeName -> "200",DynamicCompileEndpoint.getClass.getTypeName -> "getPathParams, scalaFutureToBoxedJsonResponse",APIUtil.getClass.getTypeName -> "errorJsonResponse, errorJsonResponse$default$1, errorJsonResponse$default$2, errorJsonResponse$default$3, errorJsonResponse$default$4, scalaFutureToLaFuture, futureToBoxedResponse",ErrorMessages.getClass.getTypeName -> "*",ExecutionContext.Implicits.getClass.getTypeName -> "global",JSONFactory400.getClass.getTypeName -> "createBanksJson",classOf[CallContext].getTypeName -> "*",classOf[ResourceDoc].getTypeName -> "getPathParams","scala.reflect.runtime.package$" -> "universe",PractiseEndpoint.getClass.getTypeName + "*" -> "*"]""".stripMargin // DynamicUtil.Validation.dependenciesScalaCode, not a copy of it. This line used to be a // character-for-character duplicate of the production expression, which meant an edit to @@ -154,55 +121,6 @@ class DynamicUtilTest extends FlatSpec with Matchers { dependencies2.toString contains ("code.api.util.NewStyle") shouldBe (true) } - "Sandbox.createSandbox method" should "should throw exception" taggedAs DynamicUtilsTag in { - requireSecurityManager() - val permissionList = List( -// new java.net.SocketPermission("ir.dcs.gla.ac.uk:80","connect,resolve"), - ) - - intercept[AccessControlException] { - Sandbox.createSandbox(permissionList).runInSandbox { - scala.io.Source.fromURL("https://apisandbox.openbankproject.com/") - } - } - } - - "Sandbox.createSandbox method" should "should work well" taggedAs DynamicUtilsTag in { - val permissionList = List( -// new java.net.SocketPermission("apisandbox.openbankproject.com:443","connect,resolve"), - new java.util.PropertyPermission("user.dir","read"), - ) - - Sandbox.createSandbox(permissionList).runInSandbox { -// scala.io.Source.fromURL("https://apisandbox.openbankproject.com/") - new File(".").getCanonicalPath - } - } - - "Sandbox.sandbox method test bankId" should "should throw exception" taggedAs DynamicUtilsTag in { - requireSecurityManager() - intercept[AccessControlException] { - Sandbox.sandbox(bankId= "abc").runInSandbox { - BankId("123" ) - } - } - } - - "Sandbox.sandbox method test bankId" should "should work well" taggedAs DynamicUtilsTag in { - Sandbox.sandbox(bankId= "abc").runInSandbox { - BankId("abc" ) - } - } - - "Sandbox.sandbox method test default permission" should "should throw exception" taggedAs DynamicUtilsTag in { - requireSecurityManager() - intercept[AccessControlException] { - Sandbox.sandbox(bankId= "abc").runInSandbox { - scala.io.Source.fromURL("https://apisandbox.openbankproject.com/") - } - } - } - val zson = { """ |{ diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/ExecutionContext.scala b/obp-commons/src/main/scala/com/openbankproject/commons/ExecutionContext.scala index 1ce7a42f2b..26572186c1 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/ExecutionContext.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/ExecutionContext.scala @@ -29,11 +29,9 @@ package com.openbankproject.commons import com.alibaba.ttl.TtlRunnable -import java.security.{AccessController, PrivilegedAction} import scala.concurrent.{ExecutionContext => ScalaExecutionContext} object ExecutionContext { - val enableSandbox = System.getProperty("dynamic_code_sandbox_enable", "false").toBoolean object Implicits { /** @@ -55,16 +53,9 @@ object ExecutionContext { def wrapExecutionContext(executionContext: ScalaExecutionContext): ScalaExecutionContext = { new ScalaExecutionContext{ override def execute(runnable: Runnable): Unit = { - val privilegedRunnable = if(enableSandbox) PrivilegedRunnable(runnable) else runnable - executionContext.execute(TtlRunnable.get(privilegedRunnable, true, true)) + executionContext.execute(TtlRunnable.get(runnable, true, true)) } override def reportFailure(cause: Throwable): Unit = executionContext.reportFailure(cause) } } - - def PrivilegedRunnable(runnable: Runnable): Runnable = { - val acc = AccessController.getContext - val privilegedAction: PrivilegedAction[Unit] = () => runnable.run() - () => AccessController.doPrivileged(privilegedAction, acc) - } } diff --git a/release_notes.md b/release_notes.md index 3cd53b6b57..a312e56030 100644 --- a/release_notes.md +++ b/release_notes.md @@ -2,6 +2,107 @@ ### Most recent changes at top of file ``` +Date Commit Action +23/09/2026 TBD CHANGED, action required: seven Roles are now granted per bank + rather than instance wide. They are the five Counterparty Attribute + Roles (CanCreateCounterpartyAttribute, CanGetCounterpartyAttribute, + CanGetCounterpartyAttributes, CanUpdateCounterpartyAttribute, + CanDeleteCounterpartyAttribute), CanGetAdapterInfoAtOneBank and + CanConfigureAmqpBankBroker. + + All seven were declared requiresBankId = false. That flag does more + than widen what a Role is called: it tells OBP to look the + Entitlement up at the empty bank id, ignoring whichever bank the + request was about, so one row let its holder act at every bank on + the instance, including banks onboarded later. Each of these Roles + acts on something belonging to a single bank -- a Counterparty + Attribute hangs off one bank's account, adapter information is asked + for one bank, and an AMQP broker registration is where one bank's + settlement and credit messages are published -- and every comparable + Role in OBP already names a bank. + + ACTION FOR OPERATORS: existing Entitlements are NOT migrated. A row + held at the system scope (bank_id empty) stops authorising these + endpoints on upgrade. Grant the Role again at each bank where the + holder needs it: + + POST /obp/v7.0.0/users/USER_ID/entitlements + { "bank_id": "BANK_ID", "role_name": "CanConfigureAmqpBankBroker" } + + Find who is affected before upgrading with + GET /obp/v6.0.0/entitlements (or the roles-with-counts endpoint) and + look for these Role names with an empty bank_id. There is no + automatic expansion on purpose: the old grant covered every bank, and + reproducing that would write one row per bank per holder for + permissions an operator may only have wanted at one or two of them. + + The stale system scoped rows authorise nothing after the upgrade and + can be deleted once the per bank grants are in place. + + This is the first instalment of a longer direction: Roles that let a + holder act on ANY bank are being retired in favour of Roles that name + one bank. System Roles, whose subject is the instance rather than a + bank, are not affected. + +``` +Date Commit Action +23/09/2026 TBD RENAMED props: dynamic_code_compile_validate_enable is now + dynamic_code_obp_calls_are_restricted, and + dynamic_code_compile_validate_dependencies is now + dynamic_code_allowed_obp_methods. + + The old names read as "validate that the dynamic code compiles", + which is not what they do -- the code is compiled either way. What + they control is an allowlist of the OBP methods dynamic code may + call: with the gate on, creating or updating a body that calls an + OBP method outside the list is rejected with OBP-40047 naming the + method. It is an allowlist on OBP's own API surface, NOT a sandbox: + it does not restrict file, network or reflection access, and does + not check general scala/java library calls. + + Both old names are still read, so an instance that set either keeps + its behaviour; using one logs a deprecation warning naming the new + name. Set the new name and the old one is ignored. Nothing changes + for an instance that set neither -- the gate still defaults to false, + meaning dynamic code may call any OBP method, and only matters once + allow_user_generated_scala_code is true. + + No behaviour change, names only. + +Date Commit Action +23/09/2026 TBD REMOVED: the dynamic-code sandbox, and with it the props + dynamic_code_sandbox_enable and dynamic_code_sandbox_permissions. + An instance that still sets either will simply ignore them. + + It wrapped runtime-compiled dynamic endpoint / connector code in + AccessController.doPrivileged with a restricted permission set, to + limit file, network and reflection access. SecurityManager was + removed in JDK 24 (JEP 411, completed by JEP 486) and OBP now + requires JVM 25, so doPrivileged is a pass-through on every runtime + that can run this code: the sandbox could not restrict anything, + while still costing a privileged wrapper on each dynamic call and + an ExecutionContext wrapper on every task. It is deleted rather + than left as a switch implying isolation it cannot deliver. + + Behaviour is unchanged, because the sandbox already enforced nothing + on a supported JVM. What changes is that this is now explicit: + dynamic code runs with the full privileges of the OBP process. The + controls that DO work are allow_user_generated_scala_code (the + master gate, still false by default), + dynamic_code_obp_calls_are_restricted (the allowlist of + callable OBP methods) and dynamic_code_requires_approval + (maker-checker). Anyone who believed the sandbox was containing + untrusted dynamic code should re-read those three. + + The early-return recovery that lived in the same wrapper is kept as + DynamicUtil.DynamicCodeBody.force, so `return errorResponse(...)` in + a dynamic body still yields its response rather than a 500. + CompiledObjects.sandboxEndpoint(bankId) becomes compiledEndpoint() + and DynamicCompileEndpoint.boundBankId is gone; both existed only to + select the per-bank permission set. Five Sandbox scenarios in + DynamicUtilTest are removed - three of them had been silently + cancelling on every run since the build moved to JDK 21+. + Date Commit Action 22/09/2026 TBD CONFIG CHANGE: public_obp_mcp_url now denotes the MCP instance that external clients can authenticate against (AUTH_PROVIDER=obp-oidc, From 6137fd57ea3a1bb0af908143dfa260b8749ab405 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 24 Sep 2026 00:04:47 +0200 Subject: [PATCH 2/8] Store SYS for a system level Dynamic Entity instead of NULL --- ANY_BANK_ROLE_REMOVAL_PLAN.md | 216 ++++++++++++++ DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md | 281 ++++++++++++++++++ .../code/api/util/migration/Migration.scala | 4 + .../MapppedDynamicEntityProvider.scala | 78 ++--- .../DynamicEntitySystemLevelBankIdTest.scala | 125 ++++++++ 5 files changed, 670 insertions(+), 34 deletions(-) create mode 100644 ANY_BANK_ROLE_REMOVAL_PLAN.md create mode 100644 DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md diff --git a/ANY_BANK_ROLE_REMOVAL_PLAN.md b/ANY_BANK_ROLE_REMOVAL_PLAN.md new file mode 100644 index 0000000000..709ebbb9ce --- /dev/null +++ b/ANY_BANK_ROLE_REMOVAL_PLAN.md @@ -0,0 +1,216 @@ +# Retiring the any-bank Roles — every Role names one bank + +Written 2026-09-23. Long term direction, nothing built. Track progress here by marking Roles done in +place. The first instance is already decided elsewhere: the Dynamic Entity pair +(`CanCreateAnyBankLevelDynamicEntity`, `CanGetAnyBankLevelDynamicEntities`) goes in +`DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md`, Phase 4, and this document generalises that decision to the +other sixty-one. + +**What this is not.** System Roles are not being removed. A Role whose subject is the instance +itself, such as `CanGetAnyUser`, `CanReadMetrics` or `CanGetConfig`, has no bank to name and stays +exactly as it is. What is being removed is the narrower thing: a Role that lets its holder operate +on **any bank**, on data that belongs to one bank at a time. After this work an administrator still +holds instance-wide Roles for instance-wide work; what they no longer hold is a single grant that +reaches every bank's accounts, customers or products at once. + +Working rules: the user commits, the assistant never does. Roles are retired one at a time, each +with its own migration, and a Role is only deleted once nothing declares it and every holder has +been expanded. Nothing here requires a big-bang release. + +## Why + +`requiresBankId = false` does two quite different jobs today, and only one of them is a problem. On +a Role whose subject is the instance it is simply true: there is no bank, so there is no bank id. On +a Role about per-bank data it means something else — the Role is read at the empty bank id whatever +bank was asked about, so it silently covers all of them. `APIUtil.hasEntitlement`, +`APIUtil.scala:2273-2277`: + +```scala +Entitlement.entitlement.vend.getEntitlement(if (role.requiresBankId) bankId else "", userId, role.toString) +``` + +For the second kind, three consequences follow, and they are the argument for removing those Roles +rather than documenting them better. + +1. **The blast radius is unbounded and invisible.** One row authorises an action at every bank, so no + bank's administrator can see, from their own bank's entitlements, who may act on their data. +2. **It covers banks that do not exist yet.** A bank onboarded next year is inside the grant the day + it is created, without anybody deciding that. +3. **It cannot be reasoned about per bank**, which is the unit everything else in OBP uses: accounts, + views, customers, consents and metrics are all per bank. A permission model whose unit differs + from the data model's unit is where mistakes hide. + +A per-bank Role has none of these properties: the row names the bank, the bank's administrator can +list it, and a new bank starts empty. + +## Scope — what is in and what is not + +In scope: a Role about a **per-bank resource** that reaches every bank — a Role that lets its holder +operate on any bank. Sixty-three Roles match today +(`requiresBankId = false` and a name saying any or all banks). Fifty-nine already have a per-bank +sibling, so retiring them is subtraction rather than design. The four without one need the sibling +written first: + +| Role | note | +|---|---| +| `CanCreateAnyBankLevelDynamicEntity` | handled by `DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md` | +| `CanGetAnyBankLevelDynamicEntities` | handled by `DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md` | +| `CanGetRolesWithEntitlementCountsAtAllBanks` | needs a per-bank sibling, or is arguably instance-wide reporting — decide before touching | +| `CanGetViewPermissionsAtAllBanks` | same question | + +Out of scope, and staying exactly as they are: **system Roles**, meaning Roles about resources that +are not per bank at all — `CanGetAnyUser`, `CanReadMetrics`, `CanGetConfig`, `CanGetConnectorHealth` +and the rest of the instance-wide administration set. They are system-scoped because their subject +is the instance, not because they span banks. Retiring them would mean inventing a bank id for +something that has none, which is the opposite of this plan; an instance still needs Roles for +instance-wide work. + +The test is one question, and a name is not the answer to it: **is the resource this Role acts on +owned by one bank?** If yes and the Role reaches all of them, it is in scope. If the resource has no +bank, the Role is a system Role and is not. + +Also out of scope, and deliberately so: reintroducing a wildcard bank id. `SYS` in the Dynamic Entity +work is the name of one space, not a wildcard, and nothing here should produce a bank id meaning +"all". + +## What replaces them + +A per-bank grant, one row per bank. The honest cost is that granting at twenty banks means twenty +rows, and a new bank needs a new row. Two answers, in the order they should be tried: + +1. **A bulk grant call** — one request naming a Role and a list of banks, or a Role and "every bank + this caller administers", writing the rows individually so that each bank's entitlement list + stays truthful. This is the mechanism that makes retirement affordable, and it should exist + before the first widely-held Role is retired. +2. **Groups**, which already exist: a group entitlement row carries its own `bank_id` + (`JSONFactory6.0.0.scala:2086-2095`), so groups remove the per-user multiplication but not the + per-bank one. Useful, not sufficient. + +Neither is a wildcard: after both, the rows still say which banks, and a bank created later is still +outside until someone adds it. That is the property being bought. + +## How one Role is retired + +Per Role, in this order. Each step is a separate commit and the Role keeps working throughout. + +1. **Audit.** Where is it declared in a `ResourceDoc` role list, and where is it checked inline? The + two patterns behave differently: in a doc list it is usually the second element of + `Some(List(canX, canXAtAnyBank))`, where removing it narrows the permission; inline it is often a + *bypass* inside an authorisation OR-chain ("has the view permission **or** holds this Role"), + where removing it changes who gets in by a different route. Sixteen files reference at least one + any-bank Role today, concentrated in `Http4s600.scala` (29), `Http4s400.scala` (26) and + `Http4s510.scala` (18). +2. **Make sure the per-bank sibling exists** and is declared alongside it everywhere the any-bank + one is. For fifty-nine Roles this is already true. +3. **Tell the operators.** A release note naming the Role, what stops working and how to grant it + again per bank. Existing rows are **not** migrated: a grant held at the system scope covered every + bank, and expanding it would write one row per bank per holder for a permission an operator may + only have wanted at one or two — on a 214-bank instance that is 214 rows per holder per Role. + Operators find who is affected by listing the Role's Entitlements with an empty bank id, and + re-grant deliberately. The stale rows authorise nothing afterwards and can be deleted. +4. **Remove it from the ResourceDoc role lists**, leaving the per-bank sibling. The Role still + exists and is still honoured anywhere it is checked inline. +5. **Deprecate the name** for one release: `Add Entitlement` refuses to grant it, the Glossary says + what to use instead, and existing rows keep working. An instance that scripted the old grant gets + an error that names the replacement rather than silent behaviour change. +6. **Delete** the Role and any rows left over. + +## Order + +Retire by risk, not alphabetically. Highest first: + +1. Roles that **write** per-bank data: create, update and delete on accounts, customers, products, + ATMs, branches, counterparties, attributes. +2. Roles that **grant or reveal access**: anything touching views, account access, or entitlements. +3. Roles that **read** per-bank data. +4. Roles on per-bank configuration and metadata. + +Within each band, prefer the Roles with the fewest holders on real instances — that is an operator +question, not one this repository can answer, so the first step of each retirement is asking. + +## Stopping the tide + +Retirement only converges if new any-bank Roles stop being added. **Done 2026-09-23**: +`code.api.sweep.AnyBankScopeSweepTest` holds two allowlists, each of which only ever shrinks. + +* 63 Roles whose name says any or all banks and which are declared `requiresBankId = false`. +* 17 endpoints whose URL carries `BANK_ID` while every Role they declare is system scoped, of + which **6 were fixed on 2026-09-23** and are gone from the list, leaving 11. + +Both are computed from the running API rather than from the source text: the Roles come from +`ApiRole.availableRoles` and their own `requiresBankId`, and the endpoints from +`EndpointCatalog.all`, which is `Http4s700.allResourceDocs` deduplicated by URL and verb. Each +guard fails in both directions — a new offender that is not on the list, and a line on the list +that no longer offends — so the lists cannot silently grow or go stale. Both directions were +checked by perturbing the lists and watching each one fail. + +## A related defect, already fixed + +While auditing this, the just-in-time entitlement path turned out to grant a system-scoped Role at a +bank id — a row `Add Entitlement` refuses to write by hand, at a scope no check reads — and to let +the request through because the write succeeded rather than because the permission held. Fixed in +`APIUtil.grantJustInTimeEntitlements` with tests in `JustInTimeEntitlementsTest`. + +The endpoint-side half of that mismatch is still open and belongs to this plan: seventeen endpoints +carry `BANK_ID` in their URL while every Role in their `ResourceDoc` is system-scoped, so the bank in +the URL does not narrow the permission at all. They are effectively any-bank Roles without the +name, and each one is a candidate for a per-bank sibling. The authoritative list is the allowlist in +`AnyBankScopeSweepTest`, read from the running API; the survey below groups them by what they touch: + +- ~~five counterparty attribute endpoints, v6 (`canCreateCounterpartyAttribute` and siblings)~~ — + **fixed 2026-09-23** +- `createCustomViewManagement`, v6 (`canCreateCustomView`) +- `getUsersWithAccountAccess`, v6 (`canSeeAccountAccessForAnyUser`) +- `accountCurrencyCheck` and `orphanedAccountCheck`, v5.1 (`canGetSystemIntegrity`) +- ~~`getAdapterInfoForBank`, v3.0 (`canGetAdapterInfoAtOneBank` — the name says one bank, the flag + said otherwise)~~ — **fixed 2026-09-23** +- `getAccountAccessTrace`, v7 (`canGetAccountAccessTrace`) +- the three AMQP broker endpoints, v7 (`canConfigureAmqpBankBroker`) +- two dynamic message doc endpoints, `GET` and `PUT` on `/management/banks/BANK_ID/dynamic-message-docs` +- `POST /banks/BANK_ID/utility-payments/UTILITY_TRANSACTION_REQUEST_ID/vend-result` + +The last three were missed by the source-text audit that first found this and were caught by the +guard reading the live catalog, which is the argument for the guard being runtime rather than a +grep. + +## Done so far + +**2026-09-23 — six endpoints, six Roles.** The five Counterparty Attribute Roles and +`CanGetAdapterInfoAtOneBank` are now `requiresBankId = true`. These were the cases needing no +judgement: every other attribute Role in OBP names a bank, and the adapter Role's own name said one +bank while its flag said every bank. + +Shape of the change, which is the recipe for the rest: + +1. The tests moved first. `CounterpartyAttributeTest` and `GetAdapterInfoTest` granted at the system + scope; they now grant at the bank, which made them fail against the old flags, and one new + scenario in each asserts that a grant at *another* bank authorises nothing. +2. The six flags flipped, with a comment saying why and pointing here. +3. A release note dated 23/09/2026 naming the seven Roles, saying that Entitlements held at the + system scope stop authorising these endpoints, and giving the call to grant each Role again per + bank. No migration: expansion was written and then dropped deliberately, because reproducing a + grant that covered every bank means one row per bank per holder — 214 rows each on this instance — + for a permission the operator may only have wanted at one or two banks. +4. The six lines came out of the endpoint allowlist in `AnyBankScopeSweepTest`, which the guard + requires — a stale entry fails the suite just as a new offender does. + +Tests after the change: 11 suites, 38 tests, no failures. + +Eleven endpoints remain on the list, and all of them need a decision rather than a flag: whether +`CanGetSystemIntegrity`, `CanConfigureAmqpBankBroker`, `CanGetAccountAccessTrace`, +`CanSeeAccountAccessForAnyUser`, `CanCreateCustomView`, the two dynamic message doc Roles and the +utility payments one are meant to be instance-wide or per bank. + +## Risks + +- **Retiring a bypass Role silently locks people out.** A Role used inside an OR-chain is not a + requirement, and expanding its holders per bank does not reproduce its effect. Audit step 1 exists + to catch this; when a Role is a bypass, the retirement is a behaviour change to discuss, not a + mechanical migration. +- **Operators who scripted grants.** Step 5 is what turns a silent change into an error message; + skipping it is what makes an upgrade look like a break. +- **Half-retired Roles are worse than either end state.** A Role removed from some doc lists but not + others gives two answers to the same question depending on the endpoint. One Role, one commit + series, finished. +- **The bulk grant call is load-bearing.** Without it, step 3 turns into manual work at every + instance and the plan stalls at the first widely-held Role. diff --git a/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md b/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md new file mode 100644 index 0000000000..4dfa46e55a --- /dev/null +++ b/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md @@ -0,0 +1,281 @@ +# Dynamic Entity space model — SYS is an ordinary bank id + +Written 2026-09-22. This is the only document for this work: no separate checklist. Track progress +here by marking items done in place. **Status on 2026-09-24: Phases 1, 2 and 3 are written and +green; nothing else is built.** The decisions below are settled. Two pieces are in the tree: the storage half of +Phase 1, committed as `25f384baf`, where the two data tables adopted the sentinel and took their +space-scoped unique index, and Phase 3, which refuses just-in-time entitlements in the system space. +Everything else in this plan is still to write. + +Working rules: the user commits, the assistant never does. Every phase below is independently +shippable, and the order is the order in which they block each other, not a preference. Single-suite +command for the suites this work touches: +`mvn test -pl obp-api -DfailIfNoTests=false -DwildcardSuites=code.api.v6_0_0.DynamicEntityTest,code.api.v6_0_0.DynamicEntitySystemLevelBankIdTest,code.api.v6_0_0.DynamicEntityAccessFlagsTest`. +CI test shards boot from an **empty H2**, never from the local Postgres, so anything that runs +before Schemifier must survive a database with no tables at all — reproduce that locally with +`OBP_DB_DRIVER=org.h2.Driver OBP_DB_URL="jdbc:h2:mem:OBPTest_$(date +%s);NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=10"`. + +## Vocabulary (settled 2026-09-22 — use these words and no others) + +| term | code | meaning | +|---|---|---| +| **space** | the `bankId` of a Dynamic Entity or record | the namespace an entity and its records live in. Every entity belongs to exactly one. | +| **system space** | `Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID`, the literal `SYS` | the instance-wide space. It is a space like any other, and `SYS` is its bank id everywhere: storage, URLs, roles, responses. | +| **bank space** | a real `bank_id` | the space belonging to one bank. Nothing about it is special any more; the system space is its sibling, not its parent. | +| **reference** | a field typed `reference:` | a link to a record **in the same space**. See Decision 4. | +| **cross-space reference** | not built | a future link that crosses spaces. When it arrives it gets its own type name, such as `cross-space-ref`, so that `reference:` never silently changes meaning. | + +Words retired: *system level entity* and *bank level entity* as two kinds of thing (there is one +kind of entity, in one space); *any bank* for anything Dynamic Entity related (see Decision 3). +"Create a system dynamic entity" from now on means exactly "create a dynamic entity at bank `SYS`". + +## The model we are going to + +| | today | after | +|---|---|---| +| Data URLs | `/obp/dynamic-entity/ENTITY` or `/obp/dynamic-entity/banks/BANK_ID/ENTITY`, unversioned (`Http4sDynamicEntity.scala:91`) | `/obp/v7.0.0/…/banks/BANK_ID/ENTITY` with `BANK_ID=SYS` for the system space | +| Management URLs | `/management/system-dynamic-entities` **or** `/management/banks/BANK_ID/dynamic-entities` — a real branch, different nouns | one route, `BANK_ID=SYS` for the system space | +| URL extractors | every one written twice, a `None` branch and a `Some(bankId)` branch (`DynamicEntityHelper.scala:48-146`) | one branch each | +| Entity roles | `Can…DynamicEntity_SystemENTITY` (`requiresBankId=false`) **or** `Can…DynamicEntity_ENTITY` (`requiresBankId=true`) (`DynamicEntityHelper.scala:1197-1218`) | one name per entity and operation, always `requiresBankId=true` | +| Meta roles | `CanCreateSystemLevelDynamicEntity`, `CanCreateBankLevelDynamicEntity`, `CanCreateAnyBankLevelDynamicEntity` (`ApiRole.scala:955-990`) | one per operation, bank-scoped, granted at `SYS` for the system space | +| Definition storage | `NullRef(DynamicEntity.BankId)` for system rows (`MapppedDynamicEntityProvider.scala:44, 55, 68`) | the `SYS` sentinel, as the data tables already do | +| Record storage | `SYS`, unique on `(BankId, DynamicEntityName, DynamicDataId)` (`MapppedDynamicDataProvider.scala:291`) | unchanged, this half is done | +| Response envelope | `bank_id` present for bank entities, absent for system ones | `bank_id` always present, `SYS` for the system space | +| Granting at `SYS` | whatever grants a system role today, i.e. `canCreateEntitlementAtAnyBank` | its own role, see Decision 5 | + +## What already exists (reuse, don't duplicate) + +- `Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID` (`constant.scala:83`). Its docstring currently says + the value "is an internal storage detail and is never published"; Phase 1 has to rewrite that, + because this plan makes it the published identity of the space. +- `Migration.database.prepareDynamicEntitySpaceScopedIndexes` (`Migration.scala:238`) — the back-fill + and index-drop shape to copy for the definition table, including its per-step `tableExistsByName` + guards and its migration-log guard. +- `addEntitlement` does **not** check that the bank exists (`Http4s700.scala:516-546`); it only + enforces `role.requiresBankId == body.bank_id.nonEmpty`. Grants at `SYS` therefore need no + carve-out, but every caller that used to send no `bank_id` for a system role must now send `SYS`. +- Joins already resolve within one space (`childJoinInfo`, `Http4sDynamicEntity.scala:117-120`), so + Phase 2 brings validation into line with them rather than adding a new restriction. +- `APIUtil.hasEntitlement` (`APIUtil.scala:2273-2277`) is where a role's reach is decided: a role + with `requiresBankId = false` is looked up at `""` whatever bank was asked about. That one line is + what "any bank" means, and it is why Decision 3 is expressed as a flag and not as a policy. + +## Phase 1 — the definition table adopts SYS — **done 2026-09-23, tests green** + +The data tables moved in `25f384baf`; `dynamicentity` did not, so the feature currently holds two +conventions for the same concept. + +1. `MapppedDynamicEntityProvider.scala:44, 55, 68` — replace `NullRef(DynamicEntity.BankId)` with a + comparison against the sentinel, and write the sentinel on create. +2. A `runOnce` migration, `MigrationOfDynamicEntityBankIdSentinel`, modelled on + `prepareDynamicEntitySpaceScopedIndexes`: `UPDATE dynamicentity SET bankid = 'SYS' WHERE bankid IS NULL`, + self-guarded on the table existing, logged with what it moved. +3. Rewrite the `constant.scala:83` docstring: the sentinel is the published identity of the system + space, not an internal detail filtered back out at the edge. +4. Tests: extend `DynamicEntitySystemLevelBankIdTest`, which already holds down the property that no + caller can create a bank whose id collides with the sentinel. + +Done as described, plus two things found while in the file. The row level access warning counted a +system entity's existing rows with `NullRef(DynamicData.BankId)`, which has matched nothing since the +data tables moved to the sentinel, so the warning never fired for exactly the entities most likely to +have data. And `delete`'s fallback branch matched by entity name with no bank id, which would have +removed every space's copy of an entity of that name; no caller reaches it today, because both pass a +row this provider just returned, but the signature takes any `DynamicEntityT` and +`DynamicEntityCommons` is what a caller naturally holds. + +The back fill is a step inside `prepareDynamicEntitySpaceScopedIndexes`, which Boot calls ungated, +rather than a `runOnce` migration: gated by the `migration_scripts.*` props, an instance with them off +would keep its NULLs and every system definition would become invisible. + +Tests: three scenarios in `DynamicEntitySystemLevelBankIdTest` for the sentinel, the reader and the +back fill, plus one for the scoped delete, each checked against a build without the fix. + +## Phase 2 — a reference points inside its own space — **done 2026-09-23, tests green** + +`recordExists` (`MapppedDynamicDataProvider.scala:148`, named `existsById` and taking no bank id +before this phase) answered by entity and id alone, and the +candidate type list comes from `getDynamicEntities(None, true)`, which is `findAll` across every +space (`DynamicEntityProvider.scala:375`). Before the space-scoped unique index that was harmless, +because a record id was unique instance-wide. It is not any more: a reference in bank A's entity can +now validate against a record in bank B or in `SYS`. + +1. `recordExists` takes the space and filters on it; `ReferenceType.validateRefValue` + (`DynamicEntityProvider.scala:430`) passes it through. +2. `referenceTypeNames` (`DynamicEntityProvider.scala:375`) lists only the entities of the space the + definition being validated belongs to, so an out-of-space `reference:X` is refused at definition + time with the existing "unknown type" error rather than at write time. +3. Leave the type grammar alone. Type names are matched by prefix (`RefParamRegx`) and assembled in + `DynamicEntityProvider.scala:721-724`, which is where a future `cross-space-ref:` slots in. +4. No back-fill and no compatibility window: validation runs only on writes (`validateEntityJson` is + called from the `Some(body)` branch of `invokeDynamicConnector`, i.e. CREATE and UPDATE), so + existing cross-space references keep being served and are refused only on the next re-save. +5. Tests: `DynamicEntityReferenceSpaceTest`, two scenarios — a definition in one space may not + declare `reference:` to an entity in another, and a record's reference may not resolve to a + record of the same id in another space, with the same-space case asserted alongside as the + control. Both were checked against a build with the space checks removed, where they fail. + +Done as described, with one addition: `ReferenceType.allReferenceTypeNames` keeps serving the Get +Reference Types endpoint (`Http4s600.scala:2303`), which is a catalogue of the whole instance and +has no bank in its URL. Worth revisiting when the v7 routes land, since a caller defining an entity +at a bank is currently shown types they cannot use. + +Status: the definition-side check (`referenceTypeNames(space)`) and the record-side check +(`recordExists(space, entityName, id)`) are both in place; `validateEntityJson` passes the entity's own +`bankId` through `ReferenceType.validateRefValue`. The four `println` calls that were logging every +reference validation to stdout are now `logger.debug`. + +## Phase 3 — just-in-time entitlements refuse SYS — **done 2026-09-22, tests green** + +Independent of every other phase and safe to land first. With +`create_just_in_time_entitlements=true`, a user holding `canCreateEntitlementAtOneBank` at a bank is +auto-granted whatever role they are missing there (`APIUtil.scala:2348`). Once `SYS` is a bank id, +that path would mint system-space roles on first use. + +No role satisfies a just-in-time grant at `SYS`; the request is refused with the ordinary missing-role +error and nothing is written. + +Done as follows. The just-in-time block existed twice, identically, in the deprecated +`handleAccessControlRegardingEntitlementsAndScopes` and in the live `handleAccessControlWithAuthMode`, +so it was first extracted into one `APIUtil.grantJustInTimeEntitlements` (`APIUtil.scala:2341`) that +both call; the rule therefore cannot drift between the two. The guard sits beside the existing +consent-user exclusion, and the docstring says why each of the two exclusions is there. Documented in +the Glossary Item `Entitlement` next to the consent-user sentence, and in `sample.props.template` +under `create_just_in_time_entitlements`. + +Tests: two scenarios in `JustInTimeEntitlementsTest`, held at the function rather than over HTTP +because no endpoint resolves `SYS` as a bank id until Phase 6. The first proves the refusal and that +no row is written; the second proves an ordinary bank is still granted just in time, which is the +behaviour the guard must not have broken. Both were checked against a deliberately unguarded build: +the first fails there (`true did not equal false`), so it is testing the guard and not the weather. + +## Phase 4 — every Dynamic Entity role names exactly one bank + +This is the first instance of a wider direction: `ANY_BANK_ROLE_REMOVAL_PLAN.md` generalises it to +the other sixty-one Roles that reach every bank. + +1. `ApiRole.scala:955-990` — every Dynamic Entity role becomes `requiresBankId = true`. + `CanCreateAnyBankLevelDynamicEntity` and `CanGetAnyBankLevelDynamicEntities` are removed, and with + them the `System`/`BankLevel` pairs: one name per operation. +2. `DynamicEntityInfo.canCreateRole` and its siblings (`DynamicEntityHelper.scala:1197-1218`) stop + branching on `bankId` and emit one name. +3. Call sites that list the pairs: `Http4s400.scala:1576, 1608, 1801` and `Http4s600.scala:7039`. +4. `checkEntityRole` (`Http4sDynamicEntity.scala:274`) loses its empty-string branch, and its error + message says `at Bank(SYS)` for the system space like any other. + +## Phase 5 — granting at SYS is its own permission + +New role `CanCreateEntitlementAtSystemSpace` (`requiresBankId = false`, held at `""`). It alone +authorises `bank_id == SYS`; neither `canCreateEntitlementAtOneBank@SYS` nor +`canCreateEntitlementAtAnyBank` satisfies it, and super admin keeps its bypass. Reusing +`canCreateEntitlementAtAnyBank` was considered and rejected: it is `requiresBankId = false` +(`ApiRole.scala:305`), so reusing it would rebuild the wildcard that reaches the system space, which +is the thing Decision 3 removes. + +The rule lives in one shared predicate — `APIUtil.mayGrantAt(bankId, granterUserId)` — because +several paths write entitlement rows without going through the endpoint: + +| path | where | rule | +|---|---|---| +| v7 `addEntitlement` | `Http4s700.scala:546` | the predicate | +| v2.0.0 `addEntitlement`, still live | `Http4s200.scala:1272` | the predicate | +| Entitlement request approval | `Http4s300.scala:1654` is the request; guard the approval that turns it into a row | the predicate | +| Group membership | `Http4s600.scala:2235` — joining a group grants its roles at `group.bankId` | the predicate, both when a group is created at `SYS` and when a member is added | +| Consent-carried entitlements | `ConsentUtil.scala:460` | the predicate, or refuse `SYS` in a consent outright | +| Default entitlements for new users | `APIUtil.scala:4599` | refuse a `SYS`-scoped role rather than write one | +| Just-in-time | `APIUtil.scala:2348` | refuse outright, Phase 3 | +| Dynamic Entity creator auto-grant | `Http4s600.scala:537` | **allowed** — it grants the entity's own roles to whoever was already permitted to create the entity, so it is a consequence of the create permission, not an independent grant | + +Consumer Scopes mirror roles and carry a bank id (`MappedScopesProvider.scala:115`). If a scope can +name `SYS`, it needs the same rule, or the application path bypasses the user path. + +## Phase 6 — the v7.0.0 routes + +Only now, with one storage convention, one role family and one grant rule, are the routes worth +writing. `/obp/dynamic-entity/…` keeps serving unchanged throughout; the two read the same storage. + +1. Collapse the extractor pairs in `DynamicEntityHelper.scala:48-146` to one branch each, and key + `definitionsMap` on `(String, String)` with `SYS` instead of `(Option[String], String)`. +2. **The space is resolved in one place** (decided 2026-09-23). The segment that names a space holds + a bank id or `SYS`, and exactly one function says which: a real bank is looked up as today and a + reserved space is let through with no bank. Everything that resolves a space asks that function + instead of calling `getBank` directly, so the rule is stated once rather than repeated wherever a + space is read. + + Implement it **without touching `ResourceDocMiddleware`**. `validateBank` + (`ResourceDocMiddleware.scala:614`) fires on the literal template variable `BANK_ID` and is shared + by every endpoint in OBP, so relaxing it there would let `/banks/SYS/accounts` past bank validation + for endpoints that genuinely need a bank, and they would fail further in with something worse than + a 404. Instead the Dynamic Entity ResourceDocs declare their template with `SPACE_ID`, a + non-standard all-caps variable the matcher treats as a wildcard and the middleware skips — the + documented bypass in CLAUDE.md, already used by `FIREHOSE_BANK_ID` and `NEW_ACCOUNT_ID`. The + handler then calls the resolver itself, in place of today's `bankCheck` + (`Http4sDynamicEntity.scala:173`). `SPACE_ID` is not in `ResourceDocMatcher.literalAllCapsSegments`, + so nothing else has to change. + + The served URL is unaffected: a caller still writes `/banks/obp1/...` or `/banks/SYS/...`; only the + doc's template variable is named differently, which is what the middleware matches on. + + Metrics, per-bank rate limiting, consent scoping and ABAC all take the bank id as a string and + never resolve it, so they need nothing. The two places that do resolve are the middleware, bypassed + as above, and `bankCheck`, replaced by the resolver. + + Two tests hold it down: an unknown bank id still gives 404 on a space route, and `SYS` passes + through to the handler. +3. The envelope carries `bank_id` always, `SYS` included. This is the signature change that makes + v7.0.0 the right home, and the reason the data endpoints stop being served from an unversioned + prefix: today there is no version to branch on, so this contract cannot be changed at all. +4. Management endpoints collapse to one route with `BANK_ID=SYS`. + +## Phase 7 — the entitlement migration + +One `runOnce`, logging what it moved, reading existing rows and writing new ones: + +| from | to | +|---|---| +| `Can*DynamicEntity_System` at `""` | `Can*DynamicEntity_` at `SYS` | +| `CanCreateAnyBankLevelDynamicEntity` / `CanGetAnyBankLevelDynamicEntities` at `""` | one bank-scoped row per existing bank, plus one at `SYS` | +| `canCreateEntitlementAtAnyBank` holders | one `CanCreateEntitlementAtSystemSpace` row each (Decision 6) | + +The third row is a **one-time expansion, never a standing rule**: `addEntitlement` must not go on +implying the system-space role from `canCreateEntitlementAtAnyBank`, or the separation applies only +to the people who happened to exist on migration day. + +## Phase 8 — documentation and deprecation + +The Glossary Items `Dynamic-Entities` and `Dynamic-Entity-Access-Model` describe the system/bank +split as two kinds of thing throughout, and the access-model table's five routes are written around +it. They need rewriting to the space vocabulary, including the reason Dynamic Entities are the only +role family in OBP without an `AtAnyBank` variant — without that recorded, someone will eventually +restore it as an oversight. Then deprecate `/obp/dynamic-entity/…`. + +## Decisions (settled 2026-09-22) + +1. `SYS` is the bank id of the system space everywhere — storage, URLs, roles, responses — and stops + being an internal detail filtered out at the edge. +2. "Create a system dynamic entity" means "create a dynamic entity at bank `SYS`". The management + URL branch disappears with it. +3. Every Dynamic Entity role is `requiresBankId = true` and names exactly one bank; the + `*AnyBankLevel*` roles go. Accepted cost: a bank created later needs its own grant, and an + existing "any bank" holder is expanded to one row per bank at migration time with future banks + uncovered. Groups do not soften this — a group entitlement row carries its own `bank_id` + (`JSONFactory6.0.0.scala:2086-2095`), so groups bundle users, not banks. +4. A `reference:` points inside its own space. A cross-space link may come later under its own name. +5. Granting at `SYS` requires `CanCreateEntitlementAtSystemSpace` and nothing else satisfies it. +6. The migration **does** grant that role to everyone holding `canCreateEntitlementAtAnyBank` when it + runs, so nobody is locked out on upgrade and today's effective permissions are preserved. +7. Just-in-time entitlements never grant at `SYS`. + +## Risks + +- **A missed grant path is a back door into the system space.** The table in Phase 5 is the audit; + anything added later that calls `Entitlement.entitlement.vend.addEntitlement` directly needs the + predicate too. A sweep test that greps for direct calls, in the style of + `OnBehalfOfOwnershipSweepTest`, is the way to keep it honest. +- **`SYS` reaching code that resolves a bank.** `bankCheck` is the obvious one; metrics, rate + limiting, consents and ABAC each resolve a bank from a request and will meet `SYS` for the first + time. Symptom is a 404 or an empty lookup, not an error that names the cause. +- **The wildcard is genuinely gone.** If the instance onboards banks continuously, Decision 3 turns + into standing operational work. The mitigation is a bulk-grant endpoint, not a wildcard role. +- **Phase 6 before Phase 4 would ship the ambiguity.** If the routes land while + `CanCreateAnyBankLevelDynamicEntity` still exists, that role starts covering the system space by + accident, because `SYS` will by then be a bank id and its lookup ignores which bank was asked about. 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 3031d46d78..dbd7d50546 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 @@ -241,6 +241,10 @@ object Migration extends MdcLoggable { val outcomes = List( adoptSystemLevelBankIdSentinel("dynamicdata"), adoptSystemLevelBankIdSentinel("dynamicdataaccess"), + // The definitions kept SQL NULL for a system level entity while the data tables had already + // moved. One feature, two conventions: every read had to branch, and the branch that looked + // for NULL silently found nothing once the writer had started using the sentinel. + adoptSystemLevelBankIdSentinel("dynamicentity"), dropSupersededIndex("dynamicdata", "dynamicdata_dynamicdataid"), dropSupersededIndex("dynamicdataaccess", "dynamicdataaccess_dynamicdataid_userid") ) diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala index 8efae61032..1ef2d8fd42 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicEntityProvider.scala @@ -34,40 +34,41 @@ import net.liftweb.common.{Box, Empty, EmptyBox, Full} import net.liftweb.mapper._ import net.liftweb.util.Helpers.tryo import org.apache.commons.lang3.StringUtils +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJsonFormats with MdcLoggable { - override def getById(bankId: Option[String], dynamicEntityId: String): Box[DynamicEntityT] = { - if (bankId.isEmpty)//If bankId is empty, we only return the system level entities - DynamicEntity.find( - By(DynamicEntity.DynamicEntityId, dynamicEntityId), - NullRef(DynamicEntity.BankId)) - else - DynamicEntity.find( - By(DynamicEntity.DynamicEntityId, dynamicEntityId), - By(DynamicEntity.BankId, bankId.get)) - } + /** + * The value the bank id column holds for a given space. + * + * A definition belonging to a bank stores that bank's id. A definition belonging to the system + * space stores Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than a SQL NULL, which is the + * convention the Dynamic Entity data tables already follow. Storing a real value rather than NULL + * means a query can compare the column like any other, and a unique index over it means what it + * says; a NULL compares equal to nothing, including itself. Readers still see None for a system + * level definition, because `bankId` filters the sentinel back out. + */ + private def storedBankId(bankId: Option[String]): String = + bankId.filter(_.nonEmpty).getOrElse(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + + + override def getById(bankId: Option[String], dynamicEntityId: String): Box[DynamicEntityT] = + DynamicEntity.find( + By(DynamicEntity.BankId, storedBankId(bankId)), + By(DynamicEntity.DynamicEntityId, dynamicEntityId)) override def getByEntityName(bankId: Option[String], entityName: String): Box[DynamicEntityT] = - if (bankId.isEmpty)//If Bank id is empty, we only return the system level entity - DynamicEntity.find( - By(DynamicEntity.EntityName, entityName), - NullRef(DynamicEntity.BankId) - ) - else - DynamicEntity.find( - By(DynamicEntity.BankId, bankId.get), - By(DynamicEntity.EntityName, entityName) - ) + DynamicEntity.find( + By(DynamicEntity.BankId, storedBankId(bankId)), + By(DynamicEntity.EntityName, entityName) + ) override def getDynamicEntities(bankId: Option[String], returnBothBankAndSystemLevel: Boolean): List[DynamicEntity] = { if(returnBothBankAndSystemLevel) DynamicEntity.findAll() - else if (bankId.isEmpty)//If Bank id is empty, we only return the system level entity - DynamicEntity.findAll(NullRef(DynamicEntity.BankId)) else - DynamicEntity.findAll(By(DynamicEntity.BankId, bankId.get)) + DynamicEntity.findAll(By(DynamicEntity.BankId, storedBankId(bankId))) } override def getDynamicEntitiesByUserId(userId: String): List[DynamicEntity] = { @@ -90,14 +91,12 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson // rows admin-only (no backfill). Warn so the operator grants access deliberately. val wasRowLevel = existsDynamicEntity.map(_.useRowLevelAccess).getOrElse(false) if (!wasRowLevel && dynamicEntity.useRowLevelAccess) { - val existingRowCount = dynamicEntity.bankId match { - case Some(b) => code.DynamicData.DynamicData.count( - By(code.DynamicData.DynamicData.DynamicEntityName, dynamicEntity.entityName), - By(code.DynamicData.DynamicData.BankId, b)) - case None => code.DynamicData.DynamicData.count( - By(code.DynamicData.DynamicData.DynamicEntityName, dynamicEntity.entityName), - NullRef(code.DynamicData.DynamicData.BankId)) - } + // The data table stores the system space as the sentinel, never as a SQL NULL, so both spaces + // are one comparison. Looking for NULL here counted zero rows for every system level entity, + // which meant the warning below never fired for exactly the entities most likely to have data. + val existingRowCount = code.DynamicData.DynamicData.count( + By(code.DynamicData.DynamicData.DynamicEntityName, dynamicEntity.entityName), + By(code.DynamicData.DynamicData.BankId, storedBankId(dynamicEntity.bankId))) if (existingRowCount > 0) logger.warn(s"createOrUpdate says: useRowLevelAccess switched on for entity '${dynamicEntity.entityName}' " + s"(bankId=${dynamicEntity.bankId.getOrElse("none")}) which already has $existingRowCount row(s); these are now " + @@ -112,7 +111,7 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson // Definition creator resolves to the on-behalf-of user (UserReference.DynamicEntity_UserId): // a consent user owns nothing durable. ON_BEHALF_OF_USER_ID_PLAN.md, Phase 2. .UserId(code.users.Users.users.vend.attributedUserId(dynamicEntity.userId, code.users.UserReference.DynamicEntity_UserId).openOr(dynamicEntity.userId)) - .BankId(dynamicEntity.bankId.getOrElse(null)) + .BankId(storedBankId(dynamicEntity.bankId)) .HasPersonalEntity(dynamicEntity.hasPersonalEntity) .HasPublicAccess(dynamicEntity.hasPublicAccess) .HasCommunityAccess(dynamicEntity.hasCommunityAccess) @@ -151,7 +150,14 @@ object MappedDynamicEntityProvider extends DynamicEntityProvider with CustomJson override def delete(dynamicEntity: DynamicEntityT): Box[Boolean] = Box.tryo{ dynamicEntity match { case v: DynamicEntity => DynamicEntity.delete_!(v) - case v => DynamicEntity.bulkDelete_!!(By(DynamicEntity.EntityName, v.entityName)) + // Anything that is not one of our own rows is matched by name, and a name identifies an entity + // only within one space: two spaces may each hold one called country. Without the bank id this + // deletes every space's copy. No caller reaches this branch today -- both pass a row this + // provider just returned -- but the signature takes any DynamicEntityT, and DynamicEntityCommons + // is what a caller naturally holds, so the branch is one call away from being reached. + case v => DynamicEntity.bulkDelete_!!( + By(DynamicEntity.BankId, storedBankId(v.bankId)), + By(DynamicEntity.EntityName, v.entityName)) } } @@ -181,7 +187,11 @@ class DynamicEntity extends DynamicEntityT with LongKeyedMapper[DynamicEntity] w override def entityName: String = EntityName.get override def metadataJson: String = MetadataJson.get override def userId: String = UserId.get - override def bankId: Option[String] = if (BankId.get == null || BankId.get.isEmpty) None else Some(BankId.get) + // A system level definition stores the sentinel rather than a SQL NULL; it is filtered back out + // here so every reader still sees None, exactly as before. Empty and null are still read as the + // system space, so a row written before the sentinel existed reads correctly until it is moved. + override def bankId: Option[String] = + Option(BankId.get).filterNot(_.isEmpty).filterNot(_ == DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) override def hasPersonalEntity: Boolean = HasPersonalEntity.get override def hasPublicAccess: Boolean = HasPublicAccess.get override def hasCommunityAccess: Boolean = HasCommunityAccess.get diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala index 7b89d31133..0aac2e6966 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala @@ -30,6 +30,11 @@ import java.io.File import code.DynamicData.DynamicDataProvider import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID +import code.api.util.migration.Migration +import code.dynamicEntity.{DynamicEntity, DynamicEntityCommons, DynamicEntityProvider} +import net.liftweb.db.DB +import net.liftweb.mapper.By +import net.liftweb.util.DefaultConnectionIdentifier import code.api.util.APIUtil import code.setup.ServerSetup import net.liftweb.common.Full @@ -106,6 +111,126 @@ class DynamicEntitySystemLevelBankIdTest extends ServerSetup { } } + feature("A definition records its space the same way a record does") { + + // The definitions kept a SQL NULL for the system space long after the data tables had moved to + // the sentinel, so one feature held two conventions and every read had to branch on which. These + // scenarios pin the definition side to the same rule: the column always holds a value, readers + // still see None for the system space, and a row left over from the NULL era is moved by the + // back fill that runs at boot. + + def definitionProvider = DynamicEntityProvider.connectorMethodProvider.vend + + def registerDefinition(entityName: String, space: Option[String]): String = + definitionProvider.createOrUpdate( + DynamicEntityCommons( + entityName = entityName, + metadataJson = s"""{"$entityName":{"description":"d","required":[],"properties":{"name":{"type":"string","example":"Alice"}}}}""", + dynamicEntityId = None, + userId = "definition-space-test", + bankId = space, + hasPersonalEntity = false + ) + ).openOrThrowException(s"could not register $entityName") + .dynamicEntityId.getOrElse(fail(s"no dynamicEntityId for $entityName")) + + /** The bank id column exactly as the database holds it, NULL included. */ + def storedBankIdOf(dynamicEntityId: String): Option[String] = + DynamicEntity.find(By(DynamicEntity.DynamicEntityId, dynamicEntityId)) + .map(row => Option(row.BankId.get)) + .openOrThrowException("the definition should exist") + + scenario("a system level definition stores the sentinel and still reads back as no bank", DynamicEntitySpaceScope) { + val entityName = s"definition_space_system_${APIUtil.generateUUID().take(8)}" + val id = registerDefinition(entityName, None) + + Then("the column holds the sentinel rather than a SQL NULL") + storedBankIdOf(id) should equal(Some(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)) + + And("every reader still sees a definition belonging to no bank") + definitionProvider.getByEntityName(None, entityName) + .openOrThrowException("the system space should find it").bankId should equal(None) + + And("a bank does not find it in its own space") + definitionProvider.getByEntityName(Some("bank_one"), entityName).isDefined should equal(false) + } + + scenario("a bank level definition stores its own bank id", DynamicEntitySpaceScope) { + val entityName = s"definition_space_bank_${APIUtil.generateUUID().take(8)}" + val id = registerDefinition(entityName, Some("bank_one")) + + storedBankIdOf(id) should equal(Some("bank_one")) + definitionProvider.getByEntityName(Some("bank_one"), entityName) + .openOrThrowException("that bank should find it").bankId should equal(Some("bank_one")) + + And("the system space does not find it") + definitionProvider.getByEntityName(None, entityName).isDefined should equal(false) + } + + scenario("a definition left over from the NULL era is moved by the back fill", DynamicEntitySpaceScope) { + val entityName = s"definition_space_legacy_${APIUtil.generateUUID().take(8)}" + val id = registerDefinition(entityName, None) + + Given("a row whose bank id is a SQL NULL, as an instance written before the sentinel existed") + DB.use(DefaultConnectionIdentifier) { connection => + val statement = connection.prepareStatement("UPDATE dynamicentity SET bankid = NULL WHERE dynamicentityid = ?") + try { statement.setString(1, id); statement.executeUpdate() } finally statement.close() + } + storedBankIdOf(id) should equal(None) + + When("the back fill that runs at boot is run") + Migration.database.prepareDynamicEntitySpaceScopedIndexes() + + Then("the row holds the sentinel, and the system space finds it again") + storedBankIdOf(id) should equal(Some(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)) + definitionProvider.getByEntityName(None, entityName).isDefined should equal(true) + } + } + + feature("Deleting a definition deletes it in one space only") { + + scenario("a delete given a definition this provider did not return removes only its own space", DynamicEntitySpaceScope) { + // MappedDynamicEntityProvider.delete takes any DynamicEntityT. Given one of its own rows it + // deletes by primary key, but given any other implementation -- DynamicEntityCommons is the one + // a caller naturally holds, since that is what createOrUpdate takes -- it falls back to matching + // by name, and a name identifies an entity only within its space. + val definitionProvider = DynamicEntityProvider.connectorMethodProvider.vend + val entityName = s"definition_delete_scope_${APIUtil.generateUUID().take(8)}" + def define(space: Option[String]) = definitionProvider.createOrUpdate( + DynamicEntityCommons( + entityName = entityName, + metadataJson = s"""{"$entityName":{"description":"d","required":[],"properties":{"name":{"type":"string","example":"Alice"}}}}""", + dynamicEntityId = None, + userId = "definition-delete-test", + bankId = space, + hasPersonalEntity = false + ) + ).openOrThrowException(s"could not register $entityName") + + Given(s"$entityName exists in the system space and in a bank space") + define(None) + define(Some("bank_one")) + + When("the bank's copy is deleted through a definition this provider did not return") + definitionProvider.delete( + DynamicEntityCommons( + entityName = entityName, + metadataJson = "{}", + dynamicEntityId = None, + userId = "definition-delete-test", + bankId = Some("bank_one"), + hasPersonalEntity = false + ) + ).openOrThrowException("the delete should report a result") should equal(true) + + Then("that space no longer has it") + definitionProvider.getByEntityName(Some("bank_one"), entityName).isDefined should equal(false) + + And("the system space still does") + definitionProvider.getByEntityName(None, entityName).isDefined should equal(true) + } + } + feature("The system level bank id can never be created as a real bank id") { /** From 8589f178fdbb11fdb7e229197ff8e99162d148c8 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 24 Sep 2026 04:23:41 +0200 Subject: [PATCH 3/8] Renaming Role canCreateSystemLevelDynamicEntity to canCreateDynamicEntityDefinition - and using the form CanCreateDynamicEntityRecord_ for CRUD operations . Remove distinction between system level and bank level dynamic entities because system uses bank_id = SYS. Plus associated changes in tests. For more consistent DE versioning --- ANY_BANK_ROLE_REMOVAL_PLAN.md | 60 +++++ DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md | 107 +++++++-- .../dynamic/entity/Http4sDynamicEntity.scala | 21 +- .../entity/helper/DynamicEntityHelper.scala | 50 ++-- .../code/api/util/migration/Migration.scala | 19 ++ .../MigrationOfDynamicEntityRoleNames.scala | 213 ++++++++++++++++++ .../scala/code/api/v4_0_0/Http4s400.scala | 8 +- .../scala/code/api/v6_0_0/Http4s600.scala | 7 +- .../AuthenticationTypeValidationTest.scala | 4 +- .../code/api/v4_0_0/DynamicEntityTest.scala | 49 ++-- .../api/v4_0_0/ForceErrorValidationTest.scala | 12 +- .../api/v4_0_0/JsonSchemaValidationTest.scala | 4 +- .../v6_0_0/DynamicEntityAccessFlagsTest.scala | 47 ++-- .../v6_0_0/DynamicEntityAuthModeTest.scala | 17 +- .../v6_0_0/DynamicEntityConsentUserTest.scala | 23 +- .../v6_0_0/DynamicEntityFieldRolesTest.scala | 54 ++++- ...DynamicEntityFilterAndBankAccessTest.scala | 9 +- ...DynamicEntityRoleRenameMigrationTest.scala | 142 ++++++++++++ .../DynamicEntityRowLevelAccessTest.scala | 7 +- release_notes.md | 38 ++++ 20 files changed, 752 insertions(+), 139 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala diff --git a/ANY_BANK_ROLE_REMOVAL_PLAN.md b/ANY_BANK_ROLE_REMOVAL_PLAN.md index 709ebbb9ce..2a38dbc902 100644 --- a/ANY_BANK_ROLE_REMOVAL_PLAN.md +++ b/ANY_BANK_ROLE_REMOVAL_PLAN.md @@ -17,6 +17,66 @@ Working rules: the user commits, the assistant never does. Roles are retired one with its own migration, and a Role is only deleted once nothing declares it and every holder has been expanded. Nothing here requires a big-bang release. +## The principle + +**A Role targets one space: one bank, or SYS.** Not two banks, not every bank, not "every bank that +exists plus the ones created next year". Everything below is that sentence applied to the sixty-three +Roles that currently break it. + +`SYS` is the system space, and a Role targeting it is an ordinary Role that happens to name that +space — not a more powerful class of Role. That is why granting at `SYS` needs nothing special; see +`DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md`, where a dedicated system-space granting Role was planned and +then dropped for exactly this reason. + +### How far the principle reaches — narrow now, wide later (decided 2026-09-24) + +**Now: it governs Roles about space-scoped things** — Dynamic Entities, attributes, accounts, +customers, anything owned by one bank. Roles whose subject is the instance rather than a space — +`CanGetAnyUser`, `CanReadMetrics`, `CanGetConfig`, `CanGetConnectorHealth` — keep +`requiresBankId = false` and the empty bank id. Nothing about them changes, and the empty bank id +survives as a third value beside a bank id and `SYS`. + +**Mid term: every Role names a space, and the instance-wide ones name `SYS`.** The empty bank id then +disappears, `APIUtil.hasEntitlement` stops choosing between `bankId` and `""`, and eventually the +`requiresBankId` flag itself has nothing left to say. That is the model worth arriving at; it is not +this plan, because it is product-wide rather than about the sixty-three Roles here, and it carries two +visible costs: every existing system Role grant moves from `""` to `SYS`, and every caller granting +one must start sending `bank_id: "SYS"`, which `Add Entitlement`'s +`role.requiresBankId == body.bank_id.nonEmpty` check would enforce the other way round. + +Two habits keep that door open while the narrow rule is in force. A new Role about something a bank +owns is scoped to a space from the start, never added at the empty bank id for convenience. And no +new code path should hard-code what the empty bank id means; ask the Role. + +#### Caution for whoever picks the wide move up + +The tempting shortcut is to let `SYS` mean whatever suits each endpoint: on Get Metrics it would mean +"every metric regardless of the bank id on the record", while on a Dynamic Entity endpoint it means +"the system space only". Do not do that, for two reasons. + +**An Entitlement row stops being readable.** `CanReadMetrics` at `SYS` and +`CanGetDynamicEntity_country` at `SYS` look identical in the table, in a listing, and in an audit +export, while one is instance-wide and the other is one namespace among many. Reviewing a grant would +mean knowing, per Role, which convention applies. + +**It rebuilds the any-bank Role under a new spelling.** "Metrics at every bank, including banks +created next year" is exactly `CanReadMetricsAtAnyBank`, the shape this plan exists to remove — now +satisfying the letter of "a Role targets one space" while inverting its substance, and passing the +guard test while doing it. + +The test to apply instead is whether the **resource** belongs to a space. A Dynamic Entity does: it +lives in exactly one, so the Role names that space and `SYS` is one value among many. A metrics record +does not, despite carrying a bank id column — the resource is the instance's request log, and the +column is a field of a row rather than ownership. So `CanReadMetrics` is not a space-scoped Role +granted at `SYS`; it is a Role about something with no space, which is the category the narrow rule +keeps at the empty bank id. + +If the wide move still looks right after that, it needs a value that honestly means "not +space-scoped", distinct from `SYS`. At which point there are three values again, better named. The +gain being chased was one uniform comparison and the eventual removal of `requiresBankId`; if `SYS` +has to be read per endpoint, `hasEntitlement` stops branching and every caller starts, in more places +and with less visibility. + ## Why `requiresBankId = false` does two quite different jobs today, and only one of them is a problem. On diff --git a/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md b/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md index 4dfa46e55a..05de18d90c 100644 --- a/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md +++ b/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md @@ -1,8 +1,8 @@ # Dynamic Entity space model — SYS is an ordinary bank id Written 2026-09-22. This is the only document for this work: no separate checklist. Track progress -here by marking items done in place. **Status on 2026-09-24: Phases 1, 2 and 3 are written and -green; nothing else is built.** The decisions below are settled. Two pieces are in the tree: the storage half of +here by marking items done in place. **Status on 2026-09-24: Phases 1, 2 and 3 are done, and phase 4 +for the Record Roles. Phase 5 is dropped. The Definition Roles join phase 6.** The decisions below are settled. Two pieces are in the tree: the storage half of Phase 1, committed as `25f384baf`, where the two data tables adopted the sentinel and took their space-scoped unique index, and Phase 3, which refuses just-in-time entitlements in the system space. Everything else in this plan is still to write. @@ -148,11 +148,77 @@ no row is written; the second proves an ordinary bank is still granted just in t behaviour the guard must not have broken. Both were checked against a deliberately unguarded build: the first fails there (`true did not equal false`), so it is testing the guard and not the weather. -## Phase 4 — every Dynamic Entity role names exactly one bank +## Phase 4 — every Dynamic Entity role names exactly one bank — **Record Roles done 2026-09-24, tests green; Definition Roles moved to phase 6** This is the first instance of a wider direction: `ANY_BANK_ROLE_REMOVAL_PLAN.md` generalises it to the other sixty-one Roles that reach every bank. +**Split during implementation, 2026-09-24.** Only the Record family moved. Merging the Definition +family means choosing one `requiresBankId` for the merged Role, and the system level management +endpoints — `/management/system-dynamic-entities` — carry no space in their URL, so +`ResourceDocMiddleware.authorizeRoles` resolves them at the empty bank id and a bank scoped Role can +never be satisfied there. Choosing `false` to suit them would widen the bank level Role into one grant +that authorises every bank, which is the shape this work removes. So the Definition merge and re-scope +happen in phase 6, in the same change that gives those endpoints a space. The Record Roles had no such +problem: their handler resolves the space itself. + +What that meant in practice, and what it caught: + +* `DynamicEntityInfo` emits one name per operation, all `requiresBankId = true`, covering the Record + Roles, `CanGrantDynamicEntityRowAccess_` and the auto-generated field Roles. +* `Http4sDynamicEntity` gained `spaceOf`, so a role check for a system level entity asks about `SYS` + rather than the empty bank id. +* **Two production defects surfaced**, both the same shape: the creator auto-grant in `Http4s600` and + in `Http4s400` wrote the new bank scoped Roles at the empty bank id, so whoever defined a system + level entity was locked out of it the moment they created it. Both now grant at `bankIdOrSYS`. +* Consent-carried entitlements have to name the space too; `ConsentUtil` already honours the Role's own + `requiresBankId`, so it was the caller that needed fixing. +* `MigrationOfDynamicEntityRoleNames` rewrites the stored names across Entitlements, Entitlement + Requests, Consumer Scopes and Group Role lists, moves the system level ones to `SYS`, moves a Group + holding only these Roles, and reports mixed Groups and the two `AnyBank` Roles that have no successor. + Covered by `DynamicEntityRoleRenameMigrationTest`. + +Regression after the change: 26 suites, 108 tests, no failures. + +**Naming, decided 2026-09-24.** Two families, told apart by what they gate, and neither name changes +with the space — consistency between a system operation and a bank operation is a main purpose of this +work, and a Role whose name changes with the space is the opposite of it. + +*Definition Roles* gate creating and editing the definition. *Record Roles* gate writing rows into it. +Today both are called "dynamic entity" Roles, which hides the difference: one lets you define +`country`, the other lets you put `FR` in it. + +Each row collapses the Roles on the left into the **single** Role on the right, which is then granted +at `SYS` or at a bank id. Thirteen Definition Role names become six, and each entity's four Record +Roles lose their `_System` twin. + +| Roles today (all replaced) | the one Role that replaces them | +|---|---| +| `CanCreateSystemLevelDynamicEntity`, `CanCreateBankLevelDynamicEntity`, `CanCreateAnyBankLevelDynamicEntity` | `CanCreateDynamicEntityDefinition` | +| `CanUpdateSystemLevelDynamicEntity`, `CanUpdateBankLevelDynamicEntity` | `CanUpdateDynamicEntityDefinition` | +| `CanDeleteSystemLevelDynamicEntity`, `CanDeleteBankLevelDynamicEntity` | `CanDeleteDynamicEntityDefinition` | +| `CanGetSystemLevelDynamicEntities`, `CanGetBankLevelDynamicEntities`, `CanGetAnyBankLevelDynamicEntities` | `CanGetDynamicEntityDefinitions` | +| `CanDeleteCascadeSystemDynamicEntity` | `CanDeleteCascadeDynamicEntityDefinition` | +| `CanBackupSystemDynamicEntity`, `CanBackupBankLevelDynamicEntity` | `CanBackupDynamicEntityDefinition` | +| `CanCreateDynamicEntity_SystemCountry`, `CanCreateDynamicEntity_Country` | `CanCreateDynamicEntityRecord_Country` | +| `CanGetDynamicEntity_SystemCountry`, `CanGetDynamicEntity_Country` | `CanGetDynamicEntityRecord_Country` | +| `CanUpdateDynamicEntity_SystemCountry`, `CanUpdateDynamicEntity_Country` | `CanUpdateDynamicEntityRecord_Country` | +| `CanDeleteDynamicEntity_SystemCountry`, `CanDeleteDynamicEntity_Country` | `CanDeleteDynamicEntityRecord_Country` | + +Every one of them is granted at `SYS` or at a bank id, and none is `requiresBankId = false`. + +Backup and cascade delete sit in the Definition family deliberately (confirmed 2026-09-24): backup +creates a `_BAK` definition alongside the copied rows, and cascade delete removes the definition +together with its records. Both act on the definition, whatever they do to the data underneath it. + +Two consequences to carry into the release note. For an operator this is a rename **and** a re-scope +at once: `CanCreateDynamicEntity_SystemCountry` at the empty bank id becomes +`CanCreateDynamicEntityRecord_Country` at `SYS`. And the longest generated prefix grows from +`CanCreateDynamicEntity_` to `CanCreateDynamicEntityRecord_`, 29 characters, so an entity name may be +up to 226 characters before the Role name outgrows the 255-character Entitlement column — the same +budget as before, since the old `System` variant was the same length. + + 1. `ApiRole.scala:955-990` — every Dynamic Entity role becomes `requiresBankId = true`. `CanCreateAnyBankLevelDynamicEntity` and `CanGetAnyBankLevelDynamicEntities` are removed, and with them the `System`/`BankLevel` pairs: one name per operation. @@ -162,17 +228,25 @@ the other sixty-one Roles that reach every bank. 4. `checkEntityRole` (`Http4sDynamicEntity.scala:274`) loses its empty-string branch, and its error message says `at Bank(SYS)` for the system space like any other. -## Phase 5 — granting at SYS is its own permission +## Phase 5 — granting at SYS is its own permission — **dropped 2026-09-24** + +A dedicated `CanCreateEntitlementAtSystemSpace` was going to be the only thing that authorised +`bank_id == SYS`. It is not being built, and the reason is the premise of this whole plan: `SYS` is an +ordinary space, so a grant there is exactly as consequential as a grant at `obp1` — control over that +space's entities and nothing more. The idea that reaching the system space should need a special key +was inherited from the world where "system level" meant instance-wide power, which is the thing being +removed. Granting at `SYS` therefore needs `canCreateEntitlementAtOneBank` **at SYS**, like anywhere +else, and `Add Entitlement` already accepts that because it never checks that the bank exists. -New role `CanCreateEntitlementAtSystemSpace` (`requiresBankId = false`, held at `""`). It alone -authorises `bank_id == SYS`; neither `canCreateEntitlementAtOneBank@SYS` nor -`canCreateEntitlementAtAnyBank` satisfies it, and super admin keeps its bypass. Reusing -`canCreateEntitlementAtAnyBank` was considered and rejected: it is `requiresBankId = false` -(`ApiRole.scala:305`), so reusing it would rebuild the wildcard that reaches the system space, which -is the thing Decision 3 removes. +What that leans on is that **no Role may reach every space at once**. Today +`canCreateEntitlementAtAnyBank` is `requiresBankId = false` (`ApiRole.scala:305`), so its holder can +grant anywhere, `SYS` included. That is not a system-space problem, it is the any-bank problem, and it +belongs to `ANY_BANK_ROLE_REMOVAL_PLAN.md` where the granting Roles are audited like every other pair. +A special SYS role would have been a local patch for a global gap. -The rule lives in one shared predicate — `APIUtil.mayGrantAt(bankId, granterUserId)` — because -several paths write entitlement rows without going through the endpoint: +The survey below is kept, because it is the audit the any-bank work needs: these are the paths that +write an Entitlement row without going through the Add Entitlement endpoint, and any rule about who +may grant what has to reach all of them. | path | where | rule | |---|---|---| @@ -260,9 +334,12 @@ restore it as an oversight. Then deprecate `/obp/dynamic-entity/…`. uncovered. Groups do not soften this — a group entitlement row carries its own `bank_id` (`JSONFactory6.0.0.scala:2086-2095`), so groups bundle users, not banks. 4. A `reference:` points inside its own space. A cross-space link may come later under its own name. -5. Granting at `SYS` requires `CanCreateEntitlementAtSystemSpace` and nothing else satisfies it. -6. The migration **does** grant that role to everyone holding `canCreateEntitlementAtAnyBank` when it - runs, so nobody is locked out on upgrade and today's effective permissions are preserved. +5. ~~Granting at `SYS` requires `CanCreateEntitlementAtSystemSpace`.~~ **Reversed 2026-09-24**: + `SYS` is an ordinary space, so granting there needs `canCreateEntitlementAtOneBank` at `SYS` like + any bank. Stopping one Role from reaching every space is the any-bank plan's job, not a special + case here. +6. ~~The migration grants that role to existing `canCreateEntitlementAtAnyBank` holders.~~ Moot, + since there is no such role. 7. Just-in-time entitlements never grant at `SYS`. ## Risks diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index c1b19fe8eb..89b531212c 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -170,6 +170,15 @@ object Http4sDynamicEntity extends MdcLoggable { s"$EntityNotFoundByEntityId Entity: '$entityName', entityId: '$id'" + bankId.map(b => s", bank_id: '$b'").getOrElse("") /** Resolve bankId to a Bank (404 if missing) for bank-level entities; no-op otherwise. */ + /** + * The space an Entitlement for this request is held at: a real bank id, or the system space as + * DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. Every Dynamic Entity Role names a space and none is granted + * at the empty bank id, so a role check for a system level entity has to ask about SYS; asking + * about "" would look somewhere nobody grants. + */ + private def spaceOf(bankId: Option[String]): String = + bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + private def bankCheck(bankId: Option[String], cc: Option[CallContext]): Future[(Any, Option[CallContext])] = if (bankId.isDefined) NewStyle.function.getBank(BankId(bankId.get), cc).map { case (b, c) => (b, c) } else Future.successful(("", cc)) @@ -272,10 +281,10 @@ object Http4sDynamicEntity extends MdcLoggable { /** The entity's role, checked per the entity's auth mode (entitlements, scopes, either or both). */ private def checkEntityRole(bankId: Option[String], entityName: String, boxUser: Box[User], role: ApiRole, callContext: Option[CallContext]): Future[Box[Unit]] = { - val bankIdStr = bankId.getOrElse("") + val bankIdStr = spaceOf(bankId) val userId = boxUser.map(_.userId).openOr("") val consumerId = code.api.util.APIUtil.getConsumerPrimaryKey(callContext) - val errorMessage = if (bankIdStr.isEmpty) UserHasMissingRoles + role.toString else UserHasMissingRoles + role.toString + s" at Bank($bankIdStr)" + val errorMessage = UserHasMissingRoles + role.toString + s" at Bank($bankIdStr)" Helper.booleanToFuture(errorMessage, cc = callContext) { code.api.util.APIUtil.handleAccessControlWithAuthMode(bankIdStr, userId, consumerId, List(role), authModeOf(bankId, entityName)) } @@ -291,7 +300,7 @@ object Http4sDynamicEntity extends MdcLoggable { val writeRestricted = info.map(_.writeRestrictedFields).getOrElse(Nil).toSet val authMode = authModeOf(bankId, entityName) def has(role: code.api.util.ApiRole): Boolean = - code.api.util.APIUtil.handleAccessControlWithAuthMode(bankId.getOrElse(""), userId, consumerId, List(role), authMode) + code.api.util.APIUtil.handleAccessControlWithAuthMode(spaceOf(bankId), userId, consumerId, List(role), authMode) touched.flatMap { f => if (writeRestricted.contains(f)) { val role = DynamicEntityInfo.fieldWriteRole(entityName, f, bankId, info.flatMap(_.explicitWriteRole(f))) @@ -326,7 +335,7 @@ object Http4sDynamicEntity extends MdcLoggable { val omit: Set[String] = readRestricted.filterNot { f => userIdOpt.exists { uid => val role = DynamicEntityInfo.fieldReadRole(entityName, f, bankId, info.flatMap(_.explicitReadRole(f))) - code.api.util.APIUtil.hasEntitlement(bankId.getOrElse(""), uid, role) + code.api.util.APIUtil.hasEntitlement(spaceOf(bankId), uid, role) } }.toSet if (omit.isEmpty) value else omitFields(value, omit) @@ -518,7 +527,7 @@ object Http4sDynamicEntity extends MdcLoggable { _ <- Helper.booleanToFuture(RowLevelAccessNotEnabled, 400, cc = callContext2) { isRowLevel(bankId, entityName) } _ <- Helper.booleanToFuture(s"$UserHasMissingRoles grant access on this row", 403, cc = callContext2) { aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Grant) || - hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canGrantRowAccessRole(entityName, bankId)) + hasEntitlement(spaceOf(bankId), u.userId, DynamicEntityInfo.canGrantRowAccessRole(entityName, bankId)) } } yield (u, callContext2) } @@ -775,7 +784,7 @@ object Http4sDynamicEntity extends MdcLoggable { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) (Full(u), callContext) <- authenticatedAccess(callContext0) (_, callContext) <- bankCheck(bankId, callContext) - _ <- NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) + _ <- NewStyle.function.hasEntitlement(spaceOf(bankId), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) queryPlan <- if (isGetAll) buildQueryPlan(req, bankId, entityName, callContext) else Future.successful(QueryPlan.empty) // Community reads are in-memory only; joins require the projection backend. diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index fc917b785f..644942a7b9 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -1195,37 +1195,35 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt } object DynamicEntityInfo { + + /** + * The Roles that gate a Dynamic Entity's **records** — the rows, not the schema. The schema is + * gated by the Definition Roles in ApiRole, and the two were called the same thing until this + * change, which hid the difference between being allowed to define `country` and being allowed to + * put `FR` in it. + * + * A Role names an operation and an entity; the space it applies to is the Entitlement's bank id, + * `SYS` for the system space or a real bank id. The name no longer carries the space, which is why + * there is one name per operation here rather than a pair. Every one of them requires a bank id, + * so a single grant can never reach more than the space it names. + */ def canCreateRole(entityName: String, bankId:Option[String]): ApiRole = - if(bankId.isDefined) - getOrCreateDynamicApiRole("CanCreateDynamicEntity_" + entityName, true) - else - getOrCreateDynamicApiRole("CanCreateDynamicEntity_System" + entityName, false) + getOrCreateDynamicApiRole("CanCreateDynamicEntityRecord_" + entityName, true) + def canUpdateRole(entityName: String, bankId:Option[String]): ApiRole = - if(bankId.isDefined) - getOrCreateDynamicApiRole("CanUpdateDynamicEntity_" + entityName, true) - else - getOrCreateDynamicApiRole("CanUpdateDynamicEntity_System" + entityName, false) + getOrCreateDynamicApiRole("CanUpdateDynamicEntityRecord_" + entityName, true) def canGetRole(entityName: String, bankId:Option[String]): ApiRole = - if(bankId.isDefined) - getOrCreateDynamicApiRole("CanGetDynamicEntity_" + entityName, true) - else - getOrCreateDynamicApiRole("CanGetDynamicEntity_System" + entityName, false) + getOrCreateDynamicApiRole("CanGetDynamicEntityRecord_" + entityName, true) def canDeleteRole(entityName: String, bankId:Option[String]): ApiRole = - if(bankId.isDefined) - getOrCreateDynamicApiRole("CanDeleteDynamicEntity_" + entityName, true) - else - getOrCreateDynamicApiRole("CanDeleteDynamicEntity_System" + entityName, false) + getOrCreateDynamicApiRole("CanDeleteDynamicEntityRecord_" + entityName, true) // Admin override for row-level access (§3): a holder may grant/list/revoke per-row ACL // on any row of the entity, even rows they cannot read. Ordinary owner-driven sharing // does not need this role — it goes through the row's own ACL CanGrant (§8.1). def canGrantRowAccessRole(entityName: String, bankId:Option[String]): ApiRole = - if(bankId.isDefined) - getOrCreateDynamicApiRole("CanGrantDynamicEntityRowAccess_" + entityName, true) - else - getOrCreateDynamicApiRole("CanGrantDynamicEntityRowAccess_System" + entityName, false) + getOrCreateDynamicApiRole("CanGrantDynamicEntityRowAccess_" + entityName, true) def roleNames(entityName: String, bankId:Option[String]): List[String] = List( canCreateRole(entityName, bankId), @@ -1239,17 +1237,13 @@ object DynamicEntityInfo { // (so many fields/entities can share one role); otherwise auto-generate a per-field role. def fieldWriteRole(entityName: String, fieldName: String, bankId: Option[String], explicit: Option[String]): ApiRole = explicit match { - case Some(role) => getOrCreateDynamicApiRole(role, bankId.isDefined) - case None => - if(bankId.isDefined) getOrCreateDynamicApiRole(s"CanWriteDynamicEntityField_${entityName}__${fieldName}", true) - else getOrCreateDynamicApiRole(s"CanWriteDynamicEntityField_System${entityName}__${fieldName}", false) + case Some(role) => getOrCreateDynamicApiRole(role, true) + case None => getOrCreateDynamicApiRole(s"CanWriteDynamicEntityField_${entityName}__${fieldName}", true) } def fieldReadRole(entityName: String, fieldName: String, bankId: Option[String], explicit: Option[String]): ApiRole = explicit match { - case Some(role) => getOrCreateDynamicApiRole(role, bankId.isDefined) - case None => - if(bankId.isDefined) getOrCreateDynamicApiRole(s"CanGetDynamicEntityField_${entityName}__${fieldName}", true) - else getOrCreateDynamicApiRole(s"CanGetDynamicEntityField_System${entityName}__${fieldName}", false) + case Some(role) => getOrCreateDynamicApiRole(role, true) + case None => getOrCreateDynamicApiRole(s"CanGetDynamicEntityField_${entityName}__${fieldName}", true) } } 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 dbd7d50546..215e5014dd 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 @@ -191,6 +191,7 @@ object Migration extends MdcLoggable { alterDynamicResourceDocBodyFieldsLength() alterDynamicResourceDocTextFieldsLength() alterDynamicDataIdLength() + renameDynamicEntityRoles() } /** @@ -907,6 +908,24 @@ object Migration extends MdcLoggable { } } + /** + * Move every stored Dynamic Entity Role onto its new name, and onto the system space where it used + * to sit at the empty bank id. + * + * The Roles were renamed twice over: the ones gating a definition lost their System / BankLevel + * split, and the ones gating records gained the word Record and lost their System twin. A renamed + * Role is different from a narrowed one — the old name no longer exists, so an existing grant + * authorises nothing rather than authorising less — and the mapping is exactly one-to-one, which + * is what makes it safe to do here instead of asking every operator to re-grant. The work, and + * what it deliberately leaves alone, is in [[MigrationOfDynamicEntityRoleNames]]. + */ + private def renameDynamicEntityRoles(): Boolean = { + val name = nameOf(renameDynamicEntityRoles) + runOnce(name) { + MigrationOfDynamicEntityRoleNames.renameEverywhere(name) + } + } + private def alterDynamicDataIdLength(): Boolean = { val name = nameOf(alterDynamicDataIdLength) runOnce(name) { diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala new file mode 100644 index 0000000000..3fc3cdb148 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala @@ -0,0 +1,213 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.util.migration + +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.entitlement.MappedEntitlement +import code.entitlementrequest.MappedEntitlementRequest +import code.group.Group +import code.scope.MappedScope + +/** + * Rename the Dynamic Entity Roles wherever a Role name is stored, and move the ones that were system + * level onto the system space. + * + * The Roles that gate an entity's records gained the word Record and lost their System twin, so + * `CanCreateDynamicEntity_SystemCountry` and `CanCreateDynamicEntity_Country` are both now + * `CanCreateDynamicEntityRecord_Country`. The name no longer says which space it applies to; the + * Entitlement's bank id does, and the system space is DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than + * the empty string it used to be. + * + * The Roles that gate a **definition** are deliberately untouched here. Merging their System and + * BankLevel variants into one name means choosing one `requiresBankId`, and choosing `false` — which + * the system management endpoints need, because their URLs carry no space for the middleware to read + * — would widen the bank level Role into one grant that authorises every bank. They are renamed and + * re-scoped in the same change that gives those endpoints a space in their URL; see + * DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6. + * + * Unlike a Role that merely narrows, a renamed Role leaves an existing grant meaningless rather than + * weaker: the old name no longer exists, so nothing reads the row. The rename is also exactly + * one-to-one, with no fan-out per bank, which is what makes this worth migrating rather than asking + * every operator to re-grant by hand. + * + * Which variant a stored name was is read from the row rather than from the name. An empty bank id + * means the system variant, anything else means the bank variant. That matters for the generated + * Roles, where the entity name is glued to the word System: a row named + * `CanCreateDynamicEntity_SystemFoo` is the system level Role for entity `Foo` when its bank id is + * empty, and the bank level Role for an entity actually called `SystemFoo` when it is not. + * + * Four stores hold Role names: Entitlements, Entitlement Requests, Consumer Scopes, and the + * comma-joined list on a Group. Groups need one extra step — a system level Group grants its Roles at + * its own bank id, which is empty, so a Group holding only Dynamic Entity Roles is moved to the + * system space as well; one holding a mix is left where it is and named in the log, because moving it + * would put its other Roles at a space nothing reads. + * + * Two Roles are deliberately not migrated. `CanCreateAnyBankLevelDynamicEntity` and + * `CanGetAnyBankLevelDynamicEntities` authorised every bank at once and have no single successor, so + * their holders are named in the log for an operator to re-grant deliberately, per bank. That is the + * narrowing this work exists for, and it cannot be done by a migration. + */ +object MigrationOfDynamicEntityRoleNames { + + /** Roles that authorised every bank at once, and so have no single successor. */ + private val rolesWithNoSuccessor: Set[String] = + Set("CanCreateAnyBankLevelDynamicEntity", "CanGetAnyBankLevelDynamicEntities") + + /** The generated per-entity Roles: old prefix -> new prefix. */ + private val generatedRolePrefixRenames: List[(String, String)] = List( + "CanCreateDynamicEntity_" -> "CanCreateDynamicEntityRecord_", + "CanUpdateDynamicEntity_" -> "CanUpdateDynamicEntityRecord_", + "CanGetDynamicEntity_" -> "CanGetDynamicEntityRecord_", + "CanDeleteDynamicEntity_" -> "CanDeleteDynamicEntityRecord_", + "CanGrantDynamicEntityRowAccess_" -> "CanGrantDynamicEntityRowAccess_", + "CanWriteDynamicEntityField_" -> "CanWriteDynamicEntityField_", + "CanGetDynamicEntityField_" -> "CanGetDynamicEntityField_" + ) + + /** + * The new name for a stored Role, given the bank id the row holds, or None when the Role is not one + * of ours or has no successor. `wasSystemLevel` is true when the stored bank id is empty. + */ + def renameOf(oldName: String, wasSystemLevel: Boolean): Option[String] = { + if (rolesWithNoSuccessor.contains(oldName)) None + else { + generatedRolePrefixRenames.collectFirst { + case (oldPrefix, newPrefix) if oldName.startsWith(oldPrefix) => + val remainder = oldName.drop(oldPrefix.length) + // The System twin exists only for a system level row; on a bank row the same letters are + // the beginning of the entity's own name. + val entityAndField = if (wasSystemLevel && remainder.startsWith("System")) remainder.drop("System".length) else remainder + newPrefix + entityAndField + } + } + } + + /** + * Does a Role of this name move onto the system space when its row sat at the empty bank id? + * + * The Record family does: its checks resolve the space in the handler, so a system level grant now + * belongs at DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. The Definition family does not, yet: those + * endpoints are served at URLs with no space segment, so the middleware still resolves them at the + * empty bank id and moving their rows would strand them. They move in the same change that gives + * those endpoints a space in their URL. + */ + private def movesToSystemSpace(oldName: String): Boolean = renameOf(oldName, wasSystemLevel = true).isDefined + + /** The space a renamed Role belongs at. */ + private def newBankId(oldName: String, oldBankId: String): String = + if (oldBankId.isEmpty && movesToSystemSpace(oldName)) DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID else oldBankId + + def renameEverywhere(name: String): Boolean = { + val startDate = System.currentTimeMillis() + val report = scala.collection.mutable.ListBuffer[String]() + + def renameRows( + label: String, + rows: List[(String, String, String => Unit, String => Unit)] + ): Unit = { + var renamed = 0 + val orphaned = scala.collection.mutable.ListBuffer[String]() + rows.foreach { case (roleName, bankId, setRoleName, setBankId) => + if (rolesWithNoSuccessor.contains(roleName)) orphaned += roleName + else renameOf(roleName, bankId.isEmpty).foreach { newName => + setRoleName(newName) + setBankId(newBankId(roleName, bankId)) + renamed += 1 + } + } + val orphanNote = + if (orphaned.isEmpty) "" + else s"; left alone because they authorised every bank and have no single successor: " + + orphaned.groupBy(identity).map { case (n, all) => s"$n x${all.size}" }.mkString(", ") + report += s"$label: renamed $renamed row(s)$orphanNote" + } + + if (DbFunction.tableExists(MappedEntitlement)) { + val rows = MappedEntitlement.findAll() + renameRows("Entitlements", rows.map(row => + (row.mRoleName.get, row.mBankId.get, + (n: String) => { row.mRoleName(n); () }, + (b: String) => { row.mBankId(b).save; () }))) + } + + if (DbFunction.tableExists(MappedEntitlementRequest)) { + val rows = MappedEntitlementRequest.findAll() + renameRows("Entitlement Requests", rows.map(row => + (row.mRoleName.get, row.mBankId.get, + (n: String) => { row.mRoleName(n); () }, + (b: String) => { row.mBankId(b).save; () }))) + } + + if (DbFunction.tableExists(MappedScope)) { + val rows = MappedScope.findAll() + renameRows("Consumer Scopes", rows.map(row => + (row.mRoleName.get, row.mBankId.get, + (n: String) => { row.mRoleName(n); () }, + (b: String) => { row.mBankId(b).save; () }))) + } + + if (DbFunction.tableExists(Group)) { + var renamedGroups = 0 + var movedGroups = 0 + val mixedGroups = scala.collection.mutable.ListBuffer[String]() + Group.findAll().foreach { group => + val wasSystemLevel = group.BankId.get.isEmpty + val storedRoles = group.ListOfRoles.get.split(",").toList.map(_.trim).filter(_.nonEmpty) + val renamedRoles = storedRoles.map(r => renameOf(r, wasSystemLevel).getOrElse(r)) + if (renamedRoles != storedRoles) { + group.ListOfRoles(renamedRoles.mkString(",")) + renamedGroups += 1 + } + // A system level Group grants at its own bank id, which is empty, so a Group whose Roles are + // all ours moves to the system space and keeps working. One holding a mix cannot move: its + // other Roles belong at the empty bank id and would land where nothing reads them. + // Only a Group whose Roles all move to the system space may move with them. One holding a + // Definition Role, which still resolves at the empty bank id, has to stay where it is. + val everyRoleMoves = storedRoles.nonEmpty && + storedRoles.forall(r => renameOf(r, wasSystemLevel).isDefined && movesToSystemSpace(r)) + if (wasSystemLevel && everyRoleMoves) { + group.BankId(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + movedGroups += 1 + } else if (wasSystemLevel && storedRoles.exists(r => renameOf(r, wasSystemLevel).isDefined && movesToSystemSpace(r))) { + mixedGroups += group.GroupName.get + } + group.save + } + val mixedNote = + if (mixedGroups.isEmpty) "" + else s"; left at the empty bank id because they also hold Roles that belong there, so their " + + s"Dynamic Entity Roles need granting another way: ${mixedGroups.mkString(", ")}" + report += s"Groups: renamed Roles on $renamedGroups group(s), moved $movedGroups to the system space$mixedNote" + } + + val endDate = System.currentTimeMillis() + saveLog(name, APIUtil.gitCommit, isSuccessful = true, startDate, endDate, report.mkString("; ")) + true + } +} 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 3dbb097707..5e31b9c5ac 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 @@ -1690,9 +1690,13 @@ object Http4s400 { DynamicEntityInfo.canDeleteRole(result.entityName, dynamicEntity.bankId) ) } yield { + // The Record Roles name a space, and a definition with no bank belongs to the system space, so + // the creator's grants go to DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than the empty bank id. + // Granting at "" would write rows nothing reads, locking the creator out of the entity they + // had just defined. Same rule as the v6.0.0 creation path. + val bankIdOrSYS = dynamicEntity.bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement( - dynamicEntity.bankId.getOrElse(""), cc.userId, role.toString())) + Entitlement.entitlement.vend.addEntitlement(bankIdOrSYS, cc.userId, role.toString())) val commonsData: DynamicEntityCommons = result commonsData.jValue } 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 7156c7b147..e485470ef0 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 @@ -533,8 +533,13 @@ object Http4s600 { } yield { // Creator grants target the HUMAN (see createBank): a per-consent shadow principal // must not end up owning the entity's admin roles. + // The Record Roles name a space, and a definition with no bank belongs to the system space, so + // the creator's grants go to DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than the empty bank id. + // Granting at "" would write rows nothing reads, and the creator would be locked out of the + // entity they had just defined. + val bankIdOrSYS = dynamicEntity.bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.onBehalfOfUserId, role.toString(), + Entitlement.entitlement.vend.addEntitlement(bankIdOrSYS, cc.onBehalfOfUserId, role.toString(), grantedByUserId = Some(cc.userId))) JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala index 6e594fc3e7..64fe327be5 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala @@ -354,7 +354,7 @@ class AuthenticationTypeValidationTest extends V400ServerSetup { scenario(s"We will call the endpoint $ApiEndpoint1 with invalid FooBar", ApiEndpoint1, VersionOfApi) { addOneAuthenticationTypeValidation(allowedGatewayLogin, s"OBPv4.0.0-dynamicEntity_createFooBar_") addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -371,7 +371,7 @@ class AuthenticationTypeValidationTest extends V400ServerSetup { scenario(s"We will call the endpoint $ApiEndpoint1 with valid FooBar", ApiEndpoint1, VersionOfApi) { addOneAuthenticationTypeValidation(allowedAll, s"OBPv4.0.0-dynamicEntity_createFooBar_${bankId}") addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala index 2ed5db8a32..2a7968a0f3 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala @@ -38,6 +38,7 @@ import org.json4s.native.Serialization.write import org.json4s._ import com.openbankproject.commons.util.JsonAliases._ import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID class DynamicEntityTest extends V400ServerSetup { /** @@ -1183,43 +1184,43 @@ class DynamicEntityTest extends V400ServerSetup { responseCreateFoobar.headers.map(_.get("Content-Type")).getOrElse("").toLowerCase should include("application/json") And("error should be " + UserHasMissingRoles) responseCreateFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseCreateFoobar.body.extract[ErrorMessage].message contains ("CanCreateDynamicEntity_SystemFooBar") should be (true) + responseCreateFoobar.body.extract[ErrorMessage].message contains ("CanCreateDynamicEntityRecord_FooBar") should be (true) val requestGetFoobars = (dynamicEntity_Request / "FooBar").GET <@(user2) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(403) And("error should be " + UserHasMissingRoles) responseGetFoobars.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseGetFoobars.body.extract[ErrorMessage].message contains ("CanGetDynamicEntity_SystemFooBar") should be (true) + responseGetFoobars.body.extract[ErrorMessage].message contains ("CanGetDynamicEntityRecord_FooBar") should be (true) val requestGetFoobar = (dynamicEntity_Request / "FooBar" / dynamicEntityId ).GET <@(user2) val responseGetFoobar = makeGetRequest(requestGetFoobar) responseGetFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseGetFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseGetFoobar.body.extract[ErrorMessage].message contains ("CanGetDynamicEntity_SystemFooBar") should be (true) + responseGetFoobar.body.extract[ErrorMessage].message contains ("CanGetDynamicEntityRecord_FooBar") should be (true) val requestUpdateFoobar = (dynamicEntity_Request / "FooBar" / dynamicEntityId).PUT <@(user2) val responseUpdateFoobar = makePutRequest(requestUpdateFoobar, write(foobarUpdateObject)) responseUpdateFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseUpdateFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseUpdateFoobar.body.extract[ErrorMessage].message contains ("CanUpdateDynamicEntity_SystemFooBar") should be (true) + responseUpdateFoobar.body.extract[ErrorMessage].message contains ("CanUpdateDynamicEntityRecord_FooBar") should be (true) val requestDeleteFoobar = (dynamicEntity_Request / "FooBar" / dynamicEntityId ).DELETE <@(user2) val responseDeleteFoobar = makeDeleteRequest(requestDeleteFoobar) responseDeleteFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseDeleteFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseDeleteFoobar.body.extract[ErrorMessage].message contains ("CanDeleteDynamicEntity_SystemFooBar") should be (true) + responseDeleteFoobar.body.extract[ErrorMessage].message contains ("CanDeleteDynamicEntityRecord_FooBar") should be (true) } { Then("we grant user2 the missing roles and CRUD again - SystemLevel") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanCreateDynamicEntity_SystemFooBar") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanUpdateDynamicEntity_SystemFooBar") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanGetDynamicEntity_SystemFooBar") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanDeleteDynamicEntity_SystemFooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanUpdateDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanDeleteDynamicEntityRecord_FooBar") val requestCreateFoobar = (dynamicEntity_Request / "FooBar").POST <@(user2) val responseCreateFoobar = makePostRequest(requestCreateFoobar, write(foobarObject)) responseCreateFoobar.code should equal(201) @@ -1289,14 +1290,14 @@ class DynamicEntityTest extends V400ServerSetup { responseCreateFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseCreateFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseCreateFoobar.body.extract[ErrorMessage].message contains ("CanCreateDynamicEntity_FooBar") should be (true) + responseCreateFoobar.body.extract[ErrorMessage].message contains ("CanCreateDynamicEntityRecord_FooBar") should be (true) val requestGetFoobars = (dynamicEntity_Request /"banks"/ testBankId1.value / "FooBar").GET <@(user2) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(403) And("error should be " + UserHasMissingRoles) responseGetFoobars.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseGetFoobars.body.extract[ErrorMessage].message contains ("CanGetDynamicEntity_FooBar") should be (true) + responseGetFoobars.body.extract[ErrorMessage].message contains ("CanGetDynamicEntityRecord_FooBar") should be (true) val requestGetFoobar = (dynamicEntity_Request / "banks"/ testBankId1.value / "FooBar" / dynamicEntityId ).GET <@(user2) @@ -1304,29 +1305,29 @@ class DynamicEntityTest extends V400ServerSetup { responseGetFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseGetFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseGetFoobar.body.extract[ErrorMessage].message contains ("CanGetDynamicEntity_FooBar") should be (true) + responseGetFoobar.body.extract[ErrorMessage].message contains ("CanGetDynamicEntityRecord_FooBar") should be (true) val requestUpdateFoobar = (dynamicEntity_Request / "banks"/ testBankId1.value /"FooBar" / dynamicEntityId).PUT <@(user2) val responseUpdateFoobar = makePutRequest(requestUpdateFoobar, write(foobarUpdateObject)) responseUpdateFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseUpdateFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseUpdateFoobar.body.extract[ErrorMessage].message contains ("CanUpdateDynamicEntity_FooBar") should be (true) + responseUpdateFoobar.body.extract[ErrorMessage].message contains ("CanUpdateDynamicEntityRecord_FooBar") should be (true) val requestDeleteFoobar = (dynamicEntity_Request / "banks"/ testBankId1.value /"FooBar" / dynamicEntityId ).DELETE <@(user2) val responseDeleteFoobar = makeDeleteRequest(requestDeleteFoobar) responseDeleteFoobar.code should equal(403) And("error should be " + UserHasMissingRoles) responseDeleteFoobar.body.extract[ErrorMessage].message contains (UserHasMissingRoles) should be (true) - responseDeleteFoobar.body.extract[ErrorMessage].message contains ("CanDeleteDynamicEntity_FooBar") should be (true) + responseDeleteFoobar.body.extract[ErrorMessage].message contains ("CanDeleteDynamicEntityRecord_FooBar") should be (true) } { Then("we grant user2 roles and try CRUD again") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanCreateDynamicEntity_FooBar") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanGetDynamicEntity_FooBar") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanUpdateDynamicEntity_FooBar") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanDeleteDynamicEntity_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanCreateDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanUpdateDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanDeleteDynamicEntityRecord_FooBar") val requestCreateFoobar = (dynamicEntity_Request/ "banks"/ testBankId1.value / "FooBar").POST <@(user2) val responseCreateFoobar = makePostRequest(requestCreateFoobar, write(foobarObject)) @@ -1398,7 +1399,7 @@ class DynamicEntityTest extends V400ServerSetup { } Then("we grant user2 can get FooBar role, user2 can get the foobar records. ") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanGetDynamicEntity_SystemFooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") val requestCreateFoobarUser2 = (dynamicEntity_Request / "FooBar").GET <@(user2) val responseCreateFoobarUser2 = makeGetRequest(requestCreateFoobarUser2) responseCreateFoobarUser2.code should equal(200) @@ -1465,7 +1466,7 @@ class DynamicEntityTest extends V400ServerSetup { } { Then("we grant user2 can get FooBar role ") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser3.userId, "CanGetDynamicEntity_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser3.userId, "CanGetDynamicEntityRecord_FooBar") val requestCreateFoobarUser2 = (dynamicEntity_Request/ "banks"/ testBankId1.value / "FooBar").GET <@(user3) val responseCreateFoobarUser2 = makeGetRequest(requestCreateFoobarUser2) responseCreateFoobarUser2.code should equal(200) @@ -1702,8 +1703,8 @@ class DynamicEntityTest extends V400ServerSetup { Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanCreateSystemLevelDynamicEntity.toString) Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanGetSystemLevelDynamicEntities.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanCreateDynamicEntity_SystemFooBar") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanGetDynamicEntity_SystemFooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") When("We make a request v4.0.0") val requestSystemLevel = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1) @@ -1827,8 +1828,8 @@ class DynamicEntityTest extends V400ServerSetup { scenario("User1 Create Bank Foobar, user1 and user2 both CRUD their own myFooBars.", ApiEndpoint8, VersionOfApi) { Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanCreateDynamicEntity_FooBar") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanGetDynamicEntity_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanCreateDynamicEntityRecord_FooBar") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") When("We make a request v4.0.0") val requestSystemLevel = (v4_0_0_Request / "management" / "banks" / testBankId1.value / "dynamic-entities").POST <@ (user1) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala index 30ac934c0b..b3996944c1 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala @@ -444,7 +444,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { feature(s"test dynamic entity endpoints Force-Error, version $VersionOfApi - authenticated access") { scenario(s"We will call the endpoint $ApiEndpoint3 with Force-Error have wrong format header", VersionOfApi) { addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -460,7 +460,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { scenario(s"We will call the endpoint $ApiEndpoint3 with Force-Error header value not support by current endpoint", VersionOfApi) { addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -475,7 +475,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { scenario(s"We will call the endpoint $ApiEndpoint3 with Response-Code header value is not Int", VersionOfApi) { addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -490,7 +490,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { scenario(s"We will call the endpoint $ApiEndpoint3 with correct Force-Error header value", VersionOfApi) { addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -507,7 +507,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { scenario(s"We will call the endpoint $ApiEndpoint3 with correct Force-Error header value and Response-Code value", VersionOfApi) { addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -525,7 +525,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { scenario(s"We will call the endpoint $ApiEndpoint3 with correct Force-Error header value, but 'enable.force_error=false'", VersionOfApi) { setPropsValues("enable.force_error"->"false") addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 diff --git a/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala index e262333762..c9c343ece7 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala @@ -357,7 +357,7 @@ class JsonSchemaValidationTest extends V400ServerSetup { scenario(s"We will call the endpoint $ApiEndpoint1 with invalid FooBar", ApiEndpoint1, VersionOfApi) { addOneValidation(jsonSchemaFooBar, s"OBPv4.0.0-dynamicEntity_createFooBar_") addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 @@ -374,7 +374,7 @@ class JsonSchemaValidationTest extends V400ServerSetup { scenario(s"We will call the endpoint $ApiEndpoint1 with valid FooBar", ApiEndpoint1, VersionOfApi) { addOneValidation(jsonSchemaFooBar, s"OBPv4.0.0-dynamicEntity_createFooBar_${bankId}") addSystemDynamicEntity() - addStringEntitlement("CanCreateDynamicEntity_SystemFooBar", "") + addStringEntitlement("CanCreateDynamicEntityRecord_FooBar", "") When("We make a request v4.0.0") val request = (dynamicEntity_Request / "FooBar").POST <@ user1 diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala index 8c03ff672a..1ac3f6e2f5 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala @@ -34,6 +34,7 @@ import org.json4s.native.Serialization.write import org.json4s._ import com.openbankproject.commons.util.JsonAliases._ import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID class DynamicEntityAccessFlagsTest extends V600ServerSetup { @@ -199,8 +200,8 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { Then("We should get a 403") createResponse.code should equal(403) - When("We add CanCreateDynamicEntity_Systemtest_personal_role to user2") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanCreateDynamicEntity_Systemtest_personal_role") + When("We add CanCreateDynamicEntityRecord_test_personal_role to user2") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_test_personal_role") And("We POST again") val createResponse2 = makePostRequest(createRequest, write(testDataRecord)) @@ -218,7 +219,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("We add CanCreate role to user2 and create a record") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanCreateDynamicEntity_Systemtest_personal_role") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_test_personal_role") val createRequest = (dynamicEntity_Request / "my" / "test_personal_role").POST <@(user2) val createResponse = makePostRequest(createRequest, write(testDataRecord)) createResponse.code should equal(201) @@ -229,8 +230,8 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { Then("We should get a 403") getResponse.code should equal(403) - When("We add CanGetDynamicEntity_Systemtest_personal_role to user2") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanGetDynamicEntity_Systemtest_personal_role") + When("We add CanGetDynamicEntityRecord_test_personal_role to user2") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanGetDynamicEntityRecord_test_personal_role") And("We GET again") val getResponse2 = makeGetRequest(getRequest) @@ -248,7 +249,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("We add CanCreate role to user2 and create a record") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanCreateDynamicEntity_Systemtest_personal_role") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_test_personal_role") val createRequest = (dynamicEntity_Request / "my" / "test_personal_role").POST <@(user2) val createResponse = makePostRequest(createRequest, write(testDataRecord)) createResponse.code should equal(201) @@ -261,8 +262,8 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { Then("We should get a 403") putResponse.code should equal(403) - When("We add CanUpdateDynamicEntity_Systemtest_personal_role to user2") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanUpdateDynamicEntity_Systemtest_personal_role") + When("We add CanUpdateDynamicEntityRecord_test_personal_role to user2") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanUpdateDynamicEntityRecord_test_personal_role") And("We PUT again") val putResponse2 = makePutRequest(putRequest, write(testDataRecordUpdated)) @@ -280,7 +281,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("We add CanCreate role to user2 and create a record") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanCreateDynamicEntity_Systemtest_personal_role") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_test_personal_role") val createRequest = (dynamicEntity_Request / "my" / "test_personal_role").POST <@(user2) val createResponse = makePostRequest(createRequest, write(testDataRecord)) createResponse.code should equal(201) @@ -293,8 +294,8 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { Then("We should get a 403") deleteResponse.code should equal(403) - When("We add CanDeleteDynamicEntity_Systemtest_personal_role to user2") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanDeleteDynamicEntity_Systemtest_personal_role") + When("We add CanDeleteDynamicEntityRecord_test_personal_role to user2") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanDeleteDynamicEntityRecord_test_personal_role") And("We DELETE again") val deleteResponse2 = makeDeleteRequest(deleteRequest) @@ -317,7 +318,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("We create a non-personal record via system endpoint") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_public") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_public") val createRequest = (dynamicEntity_Request / "test_public").POST <@(user1) val createResponse = makePostRequest(createRequest, write(testDataRecord)) createResponse.code should equal(201) @@ -339,7 +340,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("We create a non-personal record via system endpoint") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_public") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_public") val createRequest = (dynamicEntity_Request / "test_public").POST <@(user1) val createResponse = makePostRequest(createRequest, write(testDataRecord)) createResponse.code should equal(201) @@ -405,8 +406,8 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { Then("We should get a 403") communityGetResponse.code should equal(403) - When("We add CanGetDynamicEntity_Systemtest_community to user2") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, "CanGetDynamicEntity_Systemtest_community") + When("We add CanGetDynamicEntityRecord_test_community to user2") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanGetDynamicEntityRecord_test_community") And("We GET again") val communityGetResponse2 = makeGetRequest(communityGetRequest) @@ -434,13 +435,13 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { myCreateResponse2.code should equal(201) When("User1 creates a non-personal record via system endpoint") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_community") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_community") val sysCreateRequest = (dynamicEntity_Request / "test_community").POST <@(user1) val sysCreateResponse = makePostRequest(sysCreateRequest, write(parse("""{"name": "System Record"}"""))) sysCreateResponse.code should equal(201) When("We add CanGet role to user1 and GET /community/test_community") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanGetDynamicEntity_Systemtest_community") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanGetDynamicEntityRecord_test_community") val communityGetRequest = (dynamicEntity_Request / "community" / "test_community").GET <@(user1) val communityGetResponse = makeGetRequest(communityGetRequest) @@ -505,14 +506,14 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("We add CanCreate role and POST to /test_no_personal as user1") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_no_personal") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_no_personal") val createRequest = (dynamicEntity_Request / "test_no_personal").POST <@(user1) val createResponse = makePostRequest(createRequest, write(testDataRecord)) Then("We should get a 201") createResponse.code should equal(201) When("We add CanGet role and GET /test_no_personal as user1") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanGetDynamicEntity_Systemtest_no_personal") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanGetDynamicEntityRecord_test_no_personal") val getRequest = (dynamicEntity_Request / "test_no_personal").GET <@(user1) val getResponse = makeGetRequest(getRequest) Then("We should get a 200") @@ -540,13 +541,13 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { myPostResponse.code should equal(404) When("Non-personal CRUD with roles works normally") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_no_personal_role") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_no_personal_role") val createRequest = (dynamicEntity_Request / "test_no_personal_role").POST <@(user1) val createResponse = makePostRequest(createRequest, write(testDataRecord)) Then("We should get a 201") createResponse.code should equal(201) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanGetDynamicEntity_Systemtest_no_personal_role") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanGetDynamicEntityRecord_test_no_personal_role") val getRequest = (dynamicEntity_Request / "test_no_personal_role").GET <@(user1) val getResponse = makeGetRequest(getRequest) Then("We should get a 200") @@ -568,7 +569,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { try { When("User1 creates a personal record (with CanCreate role, since personal_requires_role=true)") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_all_flags") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_all_flags") val myCreateRequest = (dynamicEntity_Request / "my" / "test_all_flags").POST <@(user1) val myCreateResponse = makePostRequest(myCreateRequest, write(testDataRecord)) myCreateResponse.code should equal(201) @@ -585,7 +586,7 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { publicGetResponse.code should equal(200) When("Community GET with auth + CanGet role returns ALL records") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanGetDynamicEntity_Systemtest_all_flags") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanGetDynamicEntityRecord_test_all_flags") val communityGetRequest = (dynamicEntity_Request / "community" / "test_all_flags").GET <@(user1) val communityGetResponse = makeGetRequest(communityGetRequest) Then("We should get a 200") diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala index 5d2a4671f8..5bdb9765bd 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala @@ -39,6 +39,7 @@ import org.json4s.JsonDSL._ import org.json4s._ import org.json4s.native.Serialization.write import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID /** * auth_mode on a Dynamic Entity decides who may hold the roles guarding its data endpoints: @@ -79,8 +80,8 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { makeDeleteRequest((v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1)) } - def getRoleName(entityName: String): String = s"CanGetDynamicEntity_System$entityName" - def createRoleName(entityName: String): String = s"CanCreateDynamicEntity_System$entityName" + def getRoleName(entityName: String): String = s"CanGetDynamicEntityRecord_$entityName" + def createRoleName(entityName: String): String = s"CanCreateDynamicEntityRecord_$entityName" feature("auth_mode on the entity definition") { @@ -118,7 +119,7 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { val (code, body) = createSystemEntity(entityJson(entityName, None)) code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] - val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) try { val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) response.code should equal(403) @@ -134,7 +135,7 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { val (code, body) = createSystemEntity(entityJson(entityName, Some("UserOrApplication"))) code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] - val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) try { val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) response.code should equal(200) @@ -149,7 +150,7 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { val (code, body) = createSystemEntity(entityJson(entityName, Some("UserOrApplication"))) code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, getRoleName(entityName)) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, getRoleName(entityName)) try { val response = makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)) response.code should equal(200) @@ -163,7 +164,7 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { val (code, body) = createSystemEntity(entityJson(entityName, Some("UserOrApplication"))) code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] - val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) try { val response = makePostRequest((dynamicEntity_Request / entityName).POST <@(user2), write(("name" -> "x"): JObject)) response.code should equal(403) @@ -180,9 +181,9 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { code should equal(201) val entityId = (body \ "dynamic_entity_id").extract[String] try { - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, getRoleName(entityName)) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, getRoleName(entityName)) makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(403) - val scope = Scope.scope.vend.addScope("", testConsumer2.id.get.toString, getRoleName(entityName)) + val scope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString, getRoleName(entityName)) try { makeGetRequest((dynamicEntity_Request / entityName).GET <@(user2)).code should equal(200) } finally { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala index 5da7183c77..2862a3b671 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala @@ -41,6 +41,7 @@ import org.json4s.JsonDSL._ import org.json4s.native.JsonMethods.parse import org.json4s.native.Serialization.write import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID /** * Personal ("my") dynamic entity endpoints and consent users. ON_BEHALF_OF_USER_ID_PLAN.md Phase 2 and @@ -56,7 +57,7 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { private val entityName = "test_consent_personal" private val roleEntityName = "test_consent_personal_role" - private val createRole = s"CanCreateDynamicEntity_System$roleEntityName" + private val createRole = s"CanCreateDynamicEntityRecord_$roleEntityName" private def definition(name: String, personalRequiresRole: Boolean): JValue = ("entity_name" -> name) ~ @@ -87,16 +88,30 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { val base: JObject = ("everything" -> false) ~ ("views" -> JArray(Nil)) ~ - ("entitlements" -> roleNames.map(role => ("bank_id" -> "") ~ ("role_name" -> role))) ~ + // A Dynamic Entity Record Role names its space, and these entities are system level, so the + // consent has to carry SYS as the bank id; an entitlement offered at the empty bank id would be + // written where no check reads it. ConsentUtil honours the Role's own requiresBankId. + ("entitlements" -> roleNames.map(role => ("bank_id" -> emptyBankIdOrSYS(role)) ~ ("role_name" -> role))) ~ ("consumer_id" -> testConsumer.consumerId.get) ~ ("time_to_live" -> 3600) myResources.map(mr => base ~ ("my_resources" -> mr)).getOrElse(base) } /** POST a consent as user1 carrying `roleNames` and the given my_resources block; returns the raw response. */ + /** + * The bank id a Role is granted at: SYS for the Dynamic Entity Record Roles, which name their + * space, and the empty one for the Definition Roles, which do not yet. See + * DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6, after which everything here is SYS. + */ + private def emptyBankIdOrSYS(role: String): String = + if (role.contains("DynamicEntityRecord_") || role.contains("DynamicEntityField_") || + role.contains("DynamicEntityRowAccess_")) DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID + else "" + private def postConsent(roleNames: List[String], myResources: Option[JValue]) = { setPropsValues("consents.allowed" -> "true", "consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE") - roleNames.foreach(role => Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, role)) + // The granting human must hold the Role before a consent may carry it, and at the same space. + roleNames.foreach(role => Entitlement.entitlement.vend.addEntitlement(emptyBankIdOrSYS(role), resourceUser1.userId, role)) makePostRequest((v6_0_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1), write(consentBody(roleNames, myResources)), consumerKeyHeader) } @@ -130,7 +145,7 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { scenario("a consent that does not list the entity is refused on /my, roles or not", VersionOfApi, ConsentUserTag) { val dynamicEntityId = createSystemEntity(entityName) try { - val headers = consentHeaders(List(s"CanCreateDynamicEntity_System$entityName", s"CanGetDynamicEntity_System$entityName"), None) + val headers = consentHeaders(List(s"CanCreateDynamicEntityRecord_$entityName", s"CanGetDynamicEntityRecord_$entityName"), None) val create = makePostRequest((dynamicEntity_Request / "my" / entityName).POST, write(record), headers) create.code should equal(403) create.body.extract[ErrorMessage].message should include(ConsentMyResourcesMissing) 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 77cdc96746..306f28efef 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 @@ -35,6 +35,7 @@ import org.json4s.native.Serialization.write import org.json4s._ import com.openbankproject.commons.util.JsonAliases.parse import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID /** * Field-level write/read role permissions on Dynamic Entities. @@ -49,14 +50,45 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { // ==================== Helpers ==================== + // Every Role this suite grants belongs to a system level entity, and a Dynamic Entity Record or + // field Role names its space: the system space is DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, not the + // empty bank id. The Definition Roles still resolve at the empty bank id until their endpoints + // carry a space in the URL, so they are granted separately below. private def grant(role: String): Unit = - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, role) + Entitlement.entitlement.vend.addEntitlement(emptyBankIdOrSYS(role), resourceUser1.userId, role) + + /** + * The bank id to grant a Role at: the empty one, or SYS. + * + * Everything this suite grants for a system level entity -- the Record Roles, the auto-generated + * field Roles, and an explicit shared field Role the schema names itself -- is bank scoped, and its + * space is the system space, DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. The exceptions are the Definition + * Roles, which still resolve at the empty bank id because their endpoints carry no space in the + * URL; they are listed rather than pattern matched, because an explicit field Role can be named + * anything at all and would otherwise have to be guessed at. + * + * The whole helper goes away at phase 6, when the Definition endpoints gain a space and both + * families are granted the same way. + */ + private val definitionRolesStillAtTheEmptyBankId: Set[String] = Set( + CanCreateSystemLevelDynamicEntity.toString, + CanUpdateSystemLevelDynamicEntity.toString, + CanDeleteSystemLevelDynamicEntity.toString, + CanGetSystemLevelDynamicEntities.toString, + CanCreateBankLevelDynamicEntity.toString, + CanUpdateBankLevelDynamicEntity.toString, + CanDeleteBankLevelDynamicEntity.toString, + CanGetBankLevelDynamicEntities.toString + ) + + private def emptyBankIdOrSYS(role: String): String = + if (definitionRolesStillAtTheEmptyBankId.contains(role)) "" else DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID // Grant to a specific user. Creating a dynamic entity auto-grants its CRUD roles to the *creator* // (resourceUser1, via createSystemEntity), so the "no entity update role" scenarios use resourceUser2 — // a user who did not create the entity and therefore only holds what we explicitly grant here. private def grantTo(userId: String, role: String): Unit = - Entitlement.entitlement.vend.addEntitlement("", userId, role) + Entitlement.entitlement.vend.addEntitlement(emptyBankIdOrSYS(role), userId, role) private def createSystemEntity(entityJson: JValue): (Int, JValue) = { grant(CanCreateSystemLevelDynamicEntity.toString) @@ -78,12 +110,12 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { private val idName = "field_roles_test_id" // Auto-generated (system-level) field roles - private val writeInternalRole = "CanWriteDynamicEntityField_Systemfield_roles_test__internal_note" - private val readSecretRole = "CanGetDynamicEntityField_Systemfield_roles_test__secret_note" + private val writeInternalRole = "CanWriteDynamicEntityField_field_roles_test__internal_note" + private val readSecretRole = "CanGetDynamicEntityField_field_roles_test__secret_note" // Entity-level (system-level) roles - private val createRole = "CanCreateDynamicEntity_Systemfield_roles_test" - private val getRole = "CanGetDynamicEntity_Systemfield_roles_test" - private val updateRole = "CanUpdateDynamicEntity_Systemfield_roles_test" + private val createRole = "CanCreateDynamicEntityRecord_field_roles_test" + private val getRole = "CanGetDynamicEntityRecord_field_roles_test" + private val updateRole = "CanUpdateDynamicEntityRecord_field_roles_test" private val schema: JValue = parse( """ @@ -108,10 +140,10 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { // ---- Per-entity fixtures for the per-field authorisation scenarios ---- // Each scenario uses a UNIQUE entity name so the (entity-scoped) role names don't collide with grants // accumulated by earlier scenarios on resourceUser1 — that's what lets us test "role X alone". - private def createRoleFor(n: String) = s"CanCreateDynamicEntity_System$n" - private def getRoleFor(n: String) = s"CanGetDynamicEntity_System$n" - private def updateRoleFor(n: String) = s"CanUpdateDynamicEntity_System$n" - private def writeNoteRoleFor(n: String) = s"CanWriteDynamicEntityField_System${n}__internal_note" + private def createRoleFor(n: String) = s"CanCreateDynamicEntityRecord_$n" + private def getRoleFor(n: String) = s"CanGetDynamicEntityRecord_$n" + private def updateRoleFor(n: String) = s"CanUpdateDynamicEntityRecord_$n" + private def writeNoteRoleFor(n: String) = s"CanWriteDynamicEntityField_${n}__internal_note" private def fieldRolesEntity(n: String): JValue = ("entity_name" -> n) ~ diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala index 0d4aa3fceb..ec4e81f6cb 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala @@ -35,6 +35,7 @@ import org.json4s.native.Serialization.write import org.json4s._ import com.openbankproject.commons.util.JsonAliases._ import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID /** * Characterization tests for two areas of the dynamic-entity runtime CRUD that were @@ -167,7 +168,7 @@ class DynamicEntityFilterAndBankAccessTest extends V600ServerSetup { try { When("user1 creates two non-personal records via the system endpoint") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_filter_public") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_filter_public") val create = (dynamicEntity_Request / "test_filter_public").POST <@(user1) makePostRequest(create, write(record("Pub1", 10))).code should equal(201) makePostRequest(create, write(record("Pub2", 20))).code should equal(201) @@ -200,7 +201,7 @@ class DynamicEntityFilterAndBankAccessTest extends V600ServerSetup { makePostRequest(create, write(record("Com2", 200))).code should equal(201) And("user1 has the CanGet role for community access") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanGetDynamicEntity_Systemtest_filter_community") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanGetDynamicEntityRecord_test_filter_community") val base = (dynamicEntity_Request / "community" / "test_filter_community").GET <@(user1) Then("GET-all returns both") @@ -230,7 +231,7 @@ class DynamicEntityFilterAndBankAccessTest extends V600ServerSetup { try { When("user1 creates a non-personal bank-level record") - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, "CanCreateDynamicEntity_test_bank_public") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_bank_public") val create = (dynamicEntity_Request / "banks" / bankId / "test_bank_public").POST <@(user1) val createResponse = makePostRequest(create, write(record("BankPub", 1))) createResponse.code should equal(201) @@ -265,7 +266,7 @@ class DynamicEntityFilterAndBankAccessTest extends V600ServerSetup { makeGetRequest(base.GET <@(user2)).code should equal(403) When("user2 is granted the bank-level CanGet role") - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser2.userId, "CanGetDynamicEntity_test_bank_community") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser2.userId, "CanGetDynamicEntityRecord_test_bank_community") Then("the GET now returns 200") makeGetRequest(base.GET <@(user2)).code should equal(200) } finally { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala new file mode 100644 index 0000000000..f445db4a27 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala @@ -0,0 +1,142 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v6_0_0 + +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID +import code.api.util.APIUtil +import code.api.util.migration.MigrationOfDynamicEntityRoleNames +import code.entitlement.MappedEntitlement +import code.group.Group +import code.setup.ServerSetup +import net.liftweb.mapper.By +import org.scalatest.Tag + +/** + * The migration that moves stored Dynamic Entity Roles onto their new names and spaces. + * + * A Role rename is not like a Role that merely narrows: the old name stops existing, so an Entitlement + * carrying it authorises nothing at all rather than authorising less. The mapping is one-to-one, which + * is why this is migrated rather than left to every operator to re-grant, and these scenarios are what + * say the mapping is the one intended. + * + * Four things are checked, because each is a different decision rather than a different example: + * a Record Role is renamed and moved to the system space; a Definition Role is left alone, because its + * endpoints still resolve at the empty bank id until their URLs carry a space; a Role that authorised + * every bank has no successor and must be left for a human; and a Group holding only Record Roles moves + * to the system space with them, while one holding a mix stays put so that its other Roles keep working. + */ +class DynamicEntityRoleRenameMigrationTest extends ServerSetup { + + object RoleRenameMigration extends Tag("DynamicEntityRoleRenameMigration") + + private val suffix = APIUtil.generateUUID().take(8) + private val userId = s"role-rename-user-$suffix" + + private def entitlementRowsFor(roleName: String): List[MappedEntitlement] = + MappedEntitlement.findAll(By(MappedEntitlement.mRoleName, roleName), By(MappedEntitlement.mUserId, userId)) + + private def giveEntitlement(bankId: String, roleName: String): Unit = + MappedEntitlement.create.mBankId(bankId).mUserId(userId).mRoleName(roleName) + .mEntitlementId(APIUtil.generateUUID()).saveMe() + + private def makeGroup(name: String, bankId: String, roles: List[String]): Group = + Group.create.GroupId(APIUtil.generateUUID()).GroupName(name).GroupDescription("role rename test") + .BankId(bankId).ListOfRoles(roles.mkString(",")).IsEnabled(true).saveMe() + + private def reloadGroup(groupName: String): Group = + Group.find(By(Group.GroupName, groupName)).openOrThrowException(s"group $groupName should exist") + + feature("The Dynamic Entity Role rename migration") { + + scenario("it renames a Record Role and moves a system level one onto the system space", RoleRenameMigration) { + val entity = s"country_$suffix" + Given("a system level grant under the old name, and a bank level one") + giveEntitlement("", s"CanCreateDynamicEntity_System$entity") + giveEntitlement("bank_one", s"CanGetDynamicEntity_$entity") + + When("the migration runs") + MigrationOfDynamicEntityRoleNames.renameEverywhere(s"roleRenameTest_$suffix") should equal(true) + + Then("the system level grant carries the new name at the system space") + val systemRows = entitlementRowsFor(s"CanCreateDynamicEntityRecord_$entity") + systemRows.size should equal(1) + systemRows.head.mBankId.get should equal(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + + And("the bank level grant carries the new name and stays at its bank") + val bankRows = entitlementRowsFor(s"CanGetDynamicEntityRecord_$entity") + bankRows.size should equal(1) + bankRows.head.mBankId.get should equal("bank_one") + + And("nothing is left under the old names") + entitlementRowsFor(s"CanCreateDynamicEntity_System$entity") shouldBe empty + entitlementRowsFor(s"CanGetDynamicEntity_$entity") shouldBe empty + } + + scenario("it leaves a Definition Role and an any-bank Role alone", RoleRenameMigration) { + Given("a Definition Role, which still resolves at the empty bank id") + giveEntitlement("", "CanCreateSystemLevelDynamicEntity") + And("an any-bank Role, which has no single successor") + giveEntitlement("", "CanCreateAnyBankLevelDynamicEntity") + + When("the migration runs") + MigrationOfDynamicEntityRoleNames.renameEverywhere(s"roleRenameTest2_$suffix") + + Then("both are untouched, name and bank id alike") + val definitionRows = entitlementRowsFor("CanCreateSystemLevelDynamicEntity") + definitionRows.size should equal(1) + definitionRows.head.mBankId.get should equal("") + + val anyBankRows = entitlementRowsFor("CanCreateAnyBankLevelDynamicEntity") + anyBankRows.size should equal(1) + anyBankRows.head.mBankId.get should equal("") + } + + scenario("a Group of Record Roles moves to the system space; a mixed Group stays put", RoleRenameMigration) { + val entity = s"parcel_$suffix" + val pureName = s"pure_group_$suffix" + val mixedName = s"mixed_group_$suffix" + + Given("a system level Group holding only Record Roles") + makeGroup(pureName, "", List(s"CanCreateDynamicEntity_System$entity", s"CanGetDynamicEntity_System$entity")) + And("another holding a Record Role and a Definition Role") + makeGroup(mixedName, "", List(s"CanCreateDynamicEntity_System$entity", "CanCreateSystemLevelDynamicEntity")) + + When("the migration runs") + MigrationOfDynamicEntityRoleNames.renameEverywhere(s"roleRenameTest3_$suffix") + + Then("the pure Group carries the new names and has moved to the system space") + val pure = reloadGroup(pureName) + pure.ListOfRoles.get should equal(s"CanCreateDynamicEntityRecord_$entity,CanGetDynamicEntityRecord_$entity") + pure.BankId.get should equal(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + + And("the mixed Group carries the new name for its Record Role but stays at the empty bank id") + val mixed = reloadGroup(mixedName) + mixed.ListOfRoles.get should equal(s"CanCreateDynamicEntityRecord_$entity,CanCreateSystemLevelDynamicEntity") + mixed.BankId.get should equal("") + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala index a7d0d6ff22..13d17976b1 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala @@ -34,6 +34,7 @@ import org.json4s.native.Serialization.write import org.json4s._ import com.openbankproject.commons.util.JsonAliases._ import org.scalatest.Tag +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID /** * Row-level access (use_row_level_access) — see ideas/DYNAMIC_ENTITY_ROW_LEVEL_ACCESS.md. @@ -76,7 +77,7 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { ("schema" -> simpleSchema) // user1 owns the entity definition (and is auto-granted the entity roles incl. the admin grant role). - // user1 creates records (it holds CanCreateDynamicEntity_Systemtest_rl from the definition create). + // user1 creates records (it holds CanCreateDynamicEntityRecord_test_rl from the definition create). def createRecord(name: String): String = { val resp = makePostRequest((dynamicEntity_Request / "test_rl").POST <@ (user1), write(("name" -> name): JValue)) resp.code should equal(201) @@ -191,7 +192,7 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { makeGetRequest((dynamicEntity_Request / "test_rl" / id / "access").GET <@ (user3)).code should equal(403) When("user3 is granted the admin row-access role") - Entitlement.entitlement.vend.addEntitlement("", resourceUser3.userId, "CanGrantDynamicEntityRowAccess_Systemtest_rl") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser3.userId, "CanGrantDynamicEntityRowAccess_test_rl") Then("user3 can list access even though it cannot read the row") makeGetRequest((dynamicEntity_Request / "test_rl" / id / "access").GET <@ (user3)).code should equal(200) @@ -215,7 +216,7 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { code should equal(201) val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] try { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, "CanCreateDynamicEntity_Systemtest_normal_rl") + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, "CanCreateDynamicEntityRecord_test_normal_rl") val createResp = makePostRequest((dynamicEntity_Request / "test_normal_rl").POST <@ (user1), write(("name" -> "x"): JValue)) createResp.code should equal(201) val id = (createResp.body \ "test_normal_rl" \ "test_normal_rl_id").extract[String] diff --git a/release_notes.md b/release_notes.md index a312e56030..ac7d5a79f0 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,6 +1,44 @@ # Release Notes ### Most recent changes at top of file +``` +Date Commit Action +24/09/2026 TBD RENAMED and RE-SCOPED: the Roles that gate a Dynamic Entity's records. + A Role that was called CanCreateDynamicEntity_SystemCountry or + CanCreateDynamicEntity_Country is now CanCreateDynamicEntityRecord_Country + in both cases, and the same for Get, Update and Delete, for + CanGrantDynamicEntityRowAccess_, and for the auto-generated field Roles + CanWriteDynamicEntityField_ and CanGetDynamicEntityField_. + + The name no longer says which space the Role applies to; the Entitlement's + bank id does. A Role for a bank's entity is granted at that bank as before. + A Role for a system level entity is now granted at the bank id SYS rather + than at the empty bank id, because a Role that names its space cannot be + granted at no space at all. + + Two Roles are gone and cannot be migrated: + CanCreateAnyBankLevelDynamicEntity and CanGetAnyBankLevelDynamicEntities. + One Entitlement row for either authorised every bank on the instance, + including banks onboarded later, which is what this work removes. Their + holders are named in the migration log; grant the per bank Role instead, at + each bank where it is needed. + + NOTHING TO DO for an ordinary upgrade: a migration rewrites the stored + names and moves the system level ones to SYS, across Entitlements, + Entitlement Requests, Consumer Scopes and the Role list on a Group. A Group + holding only these Roles moves to SYS with them; one that also holds other + Roles stays where it is and is named in the migration log, because its + Dynamic Entity Roles then need granting another way. + + An instance that runs with migration scripts disabled must re-grant by + hand; the log entry names what would have moved. + + The Roles that gate a Dynamic Entity's DEFINITION -- creating, editing and + deleting the entity itself -- are NOT part of this change. They keep their + names and their empty bank id for now, because the system level management + endpoints carry no space in their URL for the framework to read. They + change in the release that gives those endpoints a space. + ``` Date Commit Action 23/09/2026 TBD CHANGED, action required: seven Roles are now granted per bank From 7ee78efe49b018c360cbfea153b7480f15bb122b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 24 Sep 2026 05:56:56 +0200 Subject: [PATCH 4/8] Dynamic Entity 6a --- .../dynamic/entity/Http4sDynamicEntity.scala | 32 +-- .../entity/helper/DynamicEntityHelper.scala | 182 +++++++++--------- .../PostgresProjectionBackend.scala | 6 +- .../projection/ProjectionDualWrite.scala | 2 +- .../projection/ProjectionProvisioner.scala | 2 +- .../scala/code/api/util/ConsentUtil.scala | 2 +- .../parity_allowlist.json | 8 + 7 files changed, 116 insertions(+), 118 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index 89b531212c..e9a9a27ea7 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -109,17 +109,17 @@ object Http4sDynamicEntity extends MdcLoggable { // declared `indexed` fields. Phase 1 backend = in-memory portable floor (projection backend later). private def deIndexedFields(bankId: Option[String], entityName: String): Map[String, FieldSpec] = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.indexedFields).getOrElse(Map.empty) + DynamicEntityHelper.definitionOf(bankId, entityName).map(_.indexedFields).getOrElse(Map.empty) private def deReferenceFields(bankId: Option[String], entityName: String): Map[String, String] = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.referenceFields).getOrElse(Map.empty) + DynamicEntityHelper.definitionOf(bankId, entityName).map(_.referenceFields).getOrElse(Map.empty) private def deUnindexedReferenceFields(bankId: Option[String], entityName: String): Map[String, String] = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.unindexedReferenceFields).getOrElse(Map.empty) + DynamicEntityHelper.definitionOf(bankId, entityName).map(_.unindexedReferenceFields).getOrElse(Map.empty) /** Resolve a join-target (child) entity's indexed + reference fields for the planner (same bank scope). */ private def childJoinInfo(bankId: Option[String])(child: String): Option[JoinTargetInfo] = - DynamicEntityHelper.definitionsMap.get((bankId, child)) + DynamicEntityHelper.definitionOf(bankId, child) .map(i => JoinTargetInfo(i.indexedFields, i.referenceFields, i.unindexedReferenceFields)) /** Parse + validate list-read query params into a QueryPlan; fail 400 (clear message) on any error. */ @@ -216,7 +216,7 @@ object Http4sDynamicEntity extends MdcLoggable { // ----- Field-level write permissions: POST/PUT never write write-restricted fields ----- private def writeRestrictedFieldsOf(bankId: Option[String], entityName: String): List[String] = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.writeRestrictedFields).getOrElse(Nil) + DynamicEntityHelper.definitionOf(bankId, entityName).map(_.writeRestrictedFields).getOrElse(Nil) private def stripFields(obj: JObject, fields: List[String]): JObject = if (fields.isEmpty) obj else JObject(obj.obj.filterNot(f => fields.contains(f.name))) @@ -247,7 +247,7 @@ object Http4sDynamicEntity extends MdcLoggable { // a Consumer's Scopes, either, or both. Personal ("my") endpoints and row-level (ACL) entities // always need a User: their rows belong to one. private def authModeOf(bankId: Option[String], entityName: String): EndpointAuthMode = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.endpointAuthMode).getOrElse(UserOnly) + DynamicEntityHelper.definitionOf(bankId, entityName).map(_.endpointAuthMode).getOrElse(UserOnly) /** authenticatedAccess for user modes, applicationAccess (User optional, Consumer required) for application modes. */ private def entityAccess(cc: CallContext, bankId: Option[String], entityName: String, isPersonalEntity: Boolean): Future[(Box[User], Option[CallContext])] = @@ -293,7 +293,7 @@ object Http4sDynamicEntity extends MdcLoggable { private def missingPatchRoleNames( bodyFieldNames: List[String], bankId: Option[String], entityName: String, userId: String, consumerId: String, requireEntityRole: Boolean ): List[String] = { - val info = DynamicEntityHelper.definitionsMap.get((bankId, entityName)) + val info = DynamicEntityHelper.definitionOf(bankId, entityName) // Only declared schema fields are meaningful (id/audit/unknown fields are ignored by the merge). val schemaFields = info.map(_.propertyNames).getOrElse(bodyFieldNames) val touched = bodyFieldNames.intersect(schemaFields) @@ -330,7 +330,7 @@ object Http4sDynamicEntity extends MdcLoggable { // Remove any read-restricted field the caller lacks the read role for (anonymous => userIdOpt None => omit all). private def applyReadRestrictions(value: JValue, bankId: Option[String], entityName: String, userIdOpt: Option[String]): JValue = { - val info = DynamicEntityHelper.definitionsMap.get((bankId, entityName)) + val info = DynamicEntityHelper.definitionOf(bankId, entityName) val readRestricted = info.map(_.readRestrictedFields).getOrElse(Nil) val omit: Set[String] = readRestricted.filterNot { f => userIdOpt.exists { uid => @@ -395,7 +395,7 @@ object Http4sDynamicEntity extends MdcLoggable { private def aclVend = DynamicDataAccessProvider.provider.vend private def dataVend = DynamicDataProvider.connectorMethodProvider.vend private def isRowLevel(bankId: Option[String], entityName: String): Boolean = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.useRowLevelAccess) + DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.useRowLevelAccess) private def rowLevelGet(req: Request[IO], cc: CallContext, bankId: Option[String], entityName: String, id: String): Future[JValue] = { val isGetAll = StringUtils.isBlank(id) @@ -472,7 +472,7 @@ object Http4sDynamicEntity extends MdcLoggable { _ <- Helper.booleanToFuture(s"$UserHasMissingRoles ${missingRoles.mkString(", ")}", 403, cc = callContext) { missingRoles.isEmpty } existing: Box[JValue] = dataVend.getCommunity(bankId, entityName, id).map(it => parse(it.dataJson)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } - mergedJson = mergePatch(DynamicEntityHelper.definitionsMap.get((bankId, entityName)), existing, bodyObj) + mergedJson = mergePatch(DynamicEntityHelper.definitionOf(bankId, entityName), existing, bodyObj) box: Box[JValue] = dataVend.updateCommunity(bankId, entityName, mergedJson, id).map(it => parse(it.dataJson)) singleObject: JValue = unboxResult(box, entityName) } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) @@ -589,7 +589,7 @@ object Http4sDynamicEntity extends MdcLoggable { (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) - personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) _ <- consentCoversPersonalResource(bankId, entityName, isPersonalEntity, "read", boxUser, callContext) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) @@ -632,7 +632,7 @@ object Http4sDynamicEntity extends MdcLoggable { (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) - personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) _ <- consentCoversPersonalResource(bankId, entityName, isPersonalEntity, "write", boxUser, callContext) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canCreateRole(entityName, bankId), callContext) @@ -666,7 +666,7 @@ object Http4sDynamicEntity extends MdcLoggable { (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) - personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) _ <- consentCoversPersonalResource(bankId, entityName, isPersonalEntity, "write", boxUser, callContext) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canUpdateRole(entityName, bankId), callContext) @@ -695,7 +695,7 @@ object Http4sDynamicEntity extends MdcLoggable { (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) - personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) _ <- failIf(afterIntercept(callContext, operationId), callContext) json <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")) } bodyObj = json.asInstanceOf[JObject] @@ -709,7 +709,7 @@ object Http4sDynamicEntity extends MdcLoggable { (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } // PATCH = partial update: merge incoming fields over the existing record. - mergedJson = mergePatch(DynamicEntityHelper.definitionsMap.get((bankId, entityName)), existing.asInstanceOf[Box[JValue]], bodyObj) + mergedJson = mergePatch(DynamicEntityHelper.definitionOf(bankId, entityName), existing.asInstanceOf[Box[JValue]], bodyObj) (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(mergedJson), Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box, entityName) } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) @@ -729,7 +729,7 @@ object Http4sDynamicEntity extends MdcLoggable { (boxUser, callContext) <- entityAccess(callContext0, bankId, entityName, isPersonalEntity) userIdOpt = boxUser.map(_.userId).toOption (_, callContext) <- bankCheck(bankId, callContext) - personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) + personalRequiresRole = DynamicEntityHelper.definitionOf(bankId, entityName).exists(_.personalRequiresRole) _ <- consentCoversPersonalResource(bankId, entityName, isPersonalEntity, "write", boxUser, callContext) _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) else checkEntityRole(bankId, entityName, boxUser, DynamicEntityInfo.canDeleteRole(entityName, bankId), callContext) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 644942a7b9..10a54bdf39 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -45,102 +45,87 @@ import scala.collection.mutable import scala.collection.mutable.ArrayBuffer +/** + * The path segments a Dynamic Entity URL may start with, and the space they name. + * + * A URL either names a bank — `/banks/BANK_ID/...` — or names nothing, which means the system space. + * Every extractor below used to write both forms out, once with `None` and once with `Some(bankId)`, + * which is why there were twice as many cases as shapes. Splitting the space off first leaves one + * case per shape, and is the same collapse the Roles and the storage have already had. + */ +private object SpaceSegments { + /** Split a leading `banks/BANK_ID` off the path. None as the space means the system space. */ + def unapply(url: List[String]): Option[(Option[String], List[String])] = url match { + case "banks" :: bankId :: rest => Some((Some(bankId), rest)) + case rest => Some((None, rest)) + } +} + +/** Does a definition for this entity exist in this space? */ +private object DefinitionIn { + def apply(space: Option[String], entityName: String): Boolean = + DynamicEntityHelper.definitionsMap.exists { + case ((mapSpace, mapName), info) => mapSpace == space && mapName == entityName && info.bankId == space + } + + /** As above, and the definition must also satisfy `predicate` — a flag such as hasPublicAccess. */ + def apply(space: Option[String], entityName: String, predicate: DynamicEntityInfo => Boolean): Boolean = + DynamicEntityHelper.definitionsMap.exists { + case ((mapSpace, mapName), info) => + mapSpace == space && mapName == entityName && info.bankId == space && predicate(info) + } +} + object EntityName { // unapply result structure: (BankId, entityName, id, isPersonalEntity) def unapply(url: List[String]): Option[(Option[String], String, String, Boolean)] = url match { - - //eg: /my/FooBar21 - case "my" :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasPersonalEntity) - .map(_ => (None, entityName, "", true)) - //eg: /my/FooBar21/FOO_BAR21_ID - case "my" :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasPersonalEntity) - .map(_ => (None, entityName, id, true)) - - //eg: /FooBar21 - case entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty) - .map(_ => (None, entityName, "", false)) - //eg: /FooBar21/FOO_BAR21_ID - case entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty) - .map(_ => (None, entityName, id, false)) - - - //eg: /Banks/BANK_ID/my/FooBar21 - case "banks" :: bankId :: "my" :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasPersonalEntity) - .map(_ => (Some(bankId), entityName, "", true)) - //eg: /Banks/BANK_ID/my/FooBar21/FOO_BAR21_ID - case "banks" :: bankId :: "my" :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasPersonalEntity) - .map(_ => (Some(bankId),entityName, id, true)) - - //contains Bank: - //eg: /Banks/BANK_ID/FooBar21 - case "banks" :: bankId :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId)) - .map(_ => (Some(bankId), entityName, "", false)) - //eg: /Banks/BANK_ID/FooBar21/FOO_BAR21_ID - case "banks" :: bankId :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId)) - .map(_ => (Some(bankId),entityName, id, false))//no bank: - + case SpaceSegments(space, rest) => rest match { + //eg: /FooBar21 or /banks/BANK_ID/FooBar21 + case entityName :: Nil if DefinitionIn(space, entityName) => + Some((space, entityName, "", false)) + //eg: /FooBar21/FOO_BAR21_ID or /banks/BANK_ID/FooBar21/FOO_BAR21_ID + case entityName :: id :: Nil if DefinitionIn(space, entityName) => + Some((space, entityName, id, false)) + //eg: /my/FooBar21 or /banks/BANK_ID/my/FooBar21 + case "my" :: entityName :: Nil if DefinitionIn(space, entityName, _.hasPersonalEntity) => + Some((space, entityName, "", true)) + //eg: /my/FooBar21/FOO_BAR21_ID or /banks/BANK_ID/my/FooBar21/FOO_BAR21_ID + case "my" :: entityName :: id :: Nil if DefinitionIn(space, entityName, _.hasPersonalEntity) => + Some((space, entityName, id, true)) + case _ => None + } case _ => None } } object PublicEntityName { - // unapply result structure: (BankId, entityName, id) - // Only matches entities where hasPublicAccess = true + // unapply result structure: (BankId, entityName, id). Only matches hasPublicAccess = true def unapply(url: List[String]): Option[(Option[String], String, String)] = url match { - - //eg: /public/FooBar21 - case "public" :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasPublicAccess) - .map(_ => (None, entityName, "")) - //eg: /public/FooBar21/FOO_BAR21_ID - case "public" :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasPublicAccess) - .map(_ => (None, entityName, id)) - - //eg: /banks/BANK_ID/public/FooBar21 - case "banks" :: bankId :: "public" :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasPublicAccess) - .map(_ => (Some(bankId), entityName, "")) - //eg: /banks/BANK_ID/public/FooBar21/FOO_BAR21_ID - case "banks" :: bankId :: "public" :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasPublicAccess) - .map(_ => (Some(bankId), entityName, id)) - + case SpaceSegments(space, rest) => rest match { + //eg: /public/FooBar21 or /banks/BANK_ID/public/FooBar21 + case "public" :: entityName :: Nil if DefinitionIn(space, entityName, _.hasPublicAccess) => + Some((space, entityName, "")) + //eg: /public/FooBar21/FOO_BAR21_ID or /banks/BANK_ID/public/FooBar21/FOO_BAR21_ID + case "public" :: entityName :: id :: Nil if DefinitionIn(space, entityName, _.hasPublicAccess) => + Some((space, entityName, id)) + case _ => None + } case _ => None } } object CommunityEntityName { - // unapply result structure: (BankId, entityName, id) - // Only matches entities where hasCommunityAccess = true + // unapply result structure: (BankId, entityName, id). Only matches hasCommunityAccess = true def unapply(url: List[String]): Option[(Option[String], String, String)] = url match { - - //eg: /community/FooBar21 - case "community" :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasCommunityAccess) - .map(_ => (None, entityName, "")) - //eg: /community/FooBar21/FOO_BAR21_ID - case "community" :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == None && definitionMap._1._2 == entityName && definitionMap._2.bankId.isEmpty && definitionMap._2.hasCommunityAccess) - .map(_ => (None, entityName, id)) - - //eg: /banks/BANK_ID/community/FooBar21 - case "banks" :: bankId :: "community" :: entityName :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasCommunityAccess) - .map(_ => (Some(bankId), entityName, "")) - //eg: /banks/BANK_ID/community/FooBar21/FOO_BAR21_ID - case "banks" :: bankId :: "community" :: entityName :: id :: Nil => - DynamicEntityHelper.definitionsMap.find(definitionMap => definitionMap._1._1 == Some(bankId) && definitionMap._1._2 == entityName && definitionMap._2.bankId == Some(bankId) && definitionMap._2.hasCommunityAccess) - .map(_ => (Some(bankId), entityName, id)) - + case SpaceSegments(space, rest) => rest match { + //eg: /community/FooBar21 or /banks/BANK_ID/community/FooBar21 + case "community" :: entityName :: Nil if DefinitionIn(space, entityName, _.hasCommunityAccess) => + Some((space, entityName, "")) + //eg: /community/FooBar21/FOO_BAR21_ID or /banks/BANK_ID/community/FooBar21/FOO_BAR21_ID + case "community" :: entityName :: id :: Nil if DefinitionIn(space, entityName, _.hasCommunityAccess) => + Some((space, entityName, id)) + case _ => None + } case _ => None } } @@ -151,22 +136,16 @@ object CommunityEntityName { * (row-level or not); the handler returns 400 when the entity isn't row-level. See §6. */ object EntityAccessName { - private def entityExists(bankId: Option[String], entityName: String): Boolean = - DynamicEntityHelper.definitionsMap.exists { case ((b, n), info) => b == bankId && n == entityName && info.bankId == bankId } - def unapply(url: List[String]): Option[(Option[String], String, String, Option[String])] = url match { - //eg: /FooBar21/FOO_BAR21_ID/access - case entityName :: id :: "access" :: Nil if entityExists(None, entityName) => - Some((None, entityName, id, None)) - //eg: /FooBar21/FOO_BAR21_ID/access/USER_ID - case entityName :: id :: "access" :: userId :: Nil if entityExists(None, entityName) => - Some((None, entityName, id, Some(userId))) - //eg: /banks/BANK_ID/FooBar21/FOO_BAR21_ID/access - case "banks" :: bankId :: entityName :: id :: "access" :: Nil if entityExists(Some(bankId), entityName) => - Some((Some(bankId), entityName, id, None)) - //eg: /banks/BANK_ID/FooBar21/FOO_BAR21_ID/access/USER_ID - case "banks" :: bankId :: entityName :: id :: "access" :: userId :: Nil if entityExists(Some(bankId), entityName) => - Some((Some(bankId), entityName, id, Some(userId))) + case SpaceSegments(space, rest) => rest match { + //eg: /FooBar21/FOO_BAR21_ID/access or /banks/BANK_ID/FooBar21/FOO_BAR21_ID/access + case entityName :: id :: "access" :: Nil if DefinitionIn(space, entityName) => + Some((space, entityName, id, None)) + //eg: /FooBar21/FOO_BAR21_ID/access/USER_ID (with or without the bank segment) + case entityName :: id :: "access" :: userId :: Nil if DefinitionIn(space, entityName) => + Some((space, entityName, id, Some(userId))) + case _ => None + } case _ => None } } @@ -222,6 +201,17 @@ object DynamicEntityHelper { // (Some(BankId), EntityName, DynamicEntityInfo) def definitionsMap: Map[(Option[String], String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((it.bankId, it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess, it.authMode))).toMap + /** + * The definition of one entity in one space, or None when that space holds no such entity. + * + * Callers ask by space and name rather than reaching into [[definitionsMap]], so that the shape of + * the key stays an implementation detail: it carries an Option today and will carry the bank id as + * a plain string once the system space is `SYS` everywhere. `bankId` is the space, and None is the + * system space. + */ + def definitionOf(bankId: Option[String], entityName: String): Option[DynamicEntityInfo] = + DynamicEntityHelper.definitionOf(bankId, entityName) + def dynamicEntityRoles: List[String] = NewStyle.function.getDynamicEntities(None, true).flatMap { dEntity => val baseRoles = DynamicEntityInfo.roleNames(dEntity.entityName, dEntity.bankId) // Per-field write/read roles for any restricted fields (explicit shared role, or auto-generated). diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/projection/PostgresProjectionBackend.scala b/obp-api/src/main/scala/code/api/dynamic/entity/projection/PostgresProjectionBackend.scala index 7c38760325..d0cc7f4bac 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/projection/PostgresProjectionBackend.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/projection/PostgresProjectionBackend.scala @@ -53,7 +53,7 @@ object PostgresProjectionBackend extends DynamicEntityQueryBackend { def name: String = "postgres-projection" def query(entityName: String, bankId: Option[String], userId: Option[String], isPersonalEntity: Boolean, plan: QueryPlan): IO[List[JObject]] = { - val indexed = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.indexedFields).getOrElse(Map.empty) + val indexed = DynamicEntityHelper.definitionOf(bankId, entityName).map(_.indexedFields).getOrElse(Map.empty) val safeTable = ProjectionNaming.tableName(bankId, entityName) val P = "p"; val D = "d" def columnOf(f: String): Option[String] = indexed.get(f).map(_ => s"$P." + ProjectionNaming.columnName(f)) @@ -95,7 +95,7 @@ object PostgresProjectionBackend extends DynamicEntityQueryBackend { */ private def existsFragment(join: JoinClause, parentBankId: Option[String], parentProjAlias: String, parentBlobAlias: String, callerUserId: Option[String]): Option[Fragment] = { val childEntity = join.childEntity - val childIndexed = DynamicEntityHelper.definitionsMap.get((parentBankId, childEntity)).map(_.indexedFields).getOrElse(Map.empty) + val childIndexed = DynamicEntityHelper.definitionOf(parentBankId, childEntity).map(_.indexedFields).getOrElse(Map.empty) val childTable = ProjectionNaming.tableName(parentBankId, childEntity) val cp = "cp"; val cd = "cd" def childColumnOf(f: String): Option[String] = childIndexed.get(f).map(_ => s"$cp." + ProjectionNaming.columnName(f)) @@ -125,7 +125,7 @@ object PostgresProjectionBackend extends DynamicEntityQueryBackend { /** ACL restriction for a row-level child: only rows the caller can read count toward EXISTS / NOT EXISTS. */ private def childAclFragment(childEntity: String, bankId: Option[String], childBlobAlias: String, callerUserId: Option[String]): Fragment = { - val isRowLevel = DynamicEntityHelper.definitionsMap.get((bankId, childEntity)).exists(_.useRowLevelAccess) + val isRowLevel = DynamicEntityHelper.definitionOf(bankId, childEntity).exists(_.useRowLevelAccess) (isRowLevel, callerUserId) match { case (true, Some(uid)) => fr"AND EXISTS (SELECT 1 FROM" ++ Fragment.const(s"${ProjectionStore.aclTable} acl") ++ diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionDualWrite.scala b/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionDualWrite.scala index fe774deaa9..046e774954 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionDualWrite.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionDualWrite.scala @@ -64,7 +64,7 @@ object ProjectionDualWrite extends MdcLoggable { if (!IndexingCapabilities.projectionEnabled) return val ready = ProjectionProvisioner.readyFields(bankId, entityName) if (ready.isEmpty) return - val indexed = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).map(_.indexedFields).getOrElse(Map.empty) + val indexed = DynamicEntityHelper.definitionOf(bankId, entityName).map(_.indexedFields).getOrElse(Map.empty) val scalarReady = indexed.toList.filter { case (name, spec) => spec.indexKind != OperatorMatrix.SPATIAL && ready.contains(name) } if (scalarReady.nonEmpty) f(ProjectionNaming.tableName(bankId, entityName), scalarReady) } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionProvisioner.scala b/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionProvisioner.scala index 98e379131f..7f3fcb0f31 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionProvisioner.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/projection/ProjectionProvisioner.scala @@ -84,7 +84,7 @@ object ProjectionProvisioner extends MdcLoggable { // ----- internals ----- private def indexedScalarFields(bankId: Option[String], entityName: String): List[(String, FieldSpec)] = - DynamicEntityHelper.definitionsMap.get((bankId, entityName)) + DynamicEntityHelper.definitionOf(bankId, entityName) .map(_.indexedFields).getOrElse(Map.empty) .toList.filter(_._2.indexKind != OperatorMatrix.SPATIAL) diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 5c0e5919a3..34a5c568e9 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1486,7 +1486,7 @@ object Consent extends MdcLoggable { val problems: List[String] = customerProblems ++ myResources.toList.flatMap(_.personal_dynamic_entities.getOrElse(Nil)).flatMap { entry => val bankId = Option(entry.bank_id).filter(_.nonEmpty) val where = s"personal_dynamic_entities entry (bank_id '${Option(entry.bank_id).getOrElse("")}', entity_name '${entry.entity_name}')" - val definition = code.api.dynamic.entity.helper.DynamicEntityHelper.definitionsMap.get((bankId, entry.entity_name)) + val definition = code.api.dynamic.entity.helper.DynamicEntityHelper.definitionOf(bankId, entry.entity_name) List( if (entry.entity_name == null || entry.entity_name.isEmpty) Some(s"$where: entity_name is required") else None, if (definition.isEmpty) Some(s"$where: no such dynamic entity") else None, diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 9e54338d83..402ecf6be8 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -1682,6 +1682,14 @@ "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9.", "lift_digest": "4eac30b6b708ad9f30e688ef1327a01ff7f4280f982f77063851c9483e3179c2", "http4s_digest": "36b31730de78f93aa41f09da7fe44eff78fe76dac497497e7562b84454a2e3b0" + }, + { + "version": "v6_0_0", + "endpoint": "validateDynamicResourceDoc", + "field": "description", + "reason": "The props named in this description were renamed: dynamic_code_compile_validate_enable is now dynamic_code_obp_calls_are_restricted, and dynamic_code_compile_validate_dependencies is now dynamic_code_allowed_obp_methods. The old names read as a compile check, which is not what they do; both old names are still honoured at runtime with a deprecation warning.", + "lift_digest": "047ea29aa10e0000c66a13b908ff679defb15226fd931f00b723cacd66a12928", + "http4s_digest": "f326a54473e63b08e1b47763d2bdbd7b5574bc4906f067072898ec32c58c6954" } ] } From 74ca2861880c76ffffee9a7311ea48a1995944d0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 24 Sep 2026 06:56:31 +0200 Subject: [PATCH 5/8] fix recursive loop --- .../code/api/dynamic/entity/helper/DynamicEntityHelper.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 10a54bdf39..859bbd96b3 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -210,7 +210,7 @@ object DynamicEntityHelper { * system space. */ def definitionOf(bankId: Option[String], entityName: String): Option[DynamicEntityInfo] = - DynamicEntityHelper.definitionOf(bankId, entityName) + definitionsMap.get((bankId, entityName)) def dynamicEntityRoles: List[String] = NewStyle.function.getDynamicEntities(None, true).flatMap { dEntity => val baseRoles = DynamicEntityInfo.roleNames(dEntity.entityName, dEntity.bankId) From f2cca91c97505bf0c947de2d5183f61ba5d9a38c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 24 Sep 2026 12:22:00 +0200 Subject: [PATCH 6/8] ConsentPersonalDynamicEntity.bankIdOrNoneForSystem --- .../SwaggerDefinitionsJSON.scala | 2 +- .../dynamic/entity/Http4sDynamicEntity.scala | 2 +- .../scala/code/api/util/ConsentUtil.scala | 16 ++++++++++-- .../main/scala/code/api/util/Glossary.scala | 2 +- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 5 ++-- .../v6_0_0/DynamicEntityConsentUserTest.scala | 26 +++++++++++++++++-- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 1549b7fcc9..1c588bf76a 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -4736,7 +4736,7 @@ object SwaggerDefinitionsJSON { consent_request_id = None, valid_from = Some(new Date()), time_to_live = Some(3600), - my_resources = Some(code.api.v6_0_0.PostConsentMyResourcesJson(Some(List(code.api.v6_0_0.PostConsentPersonalDynamicEntityJson("", "FooBar", List("read", "write")))))) + my_resources = Some(code.api.v6_0_0.PostConsentMyResourcesJson(Some(List(code.api.v6_0_0.PostConsentPersonalDynamicEntityJson("SYS", "FooBar", List("read", "write")))))) ) lazy val consentsJsonV310 = ConsentsJsonV310(List(consentJsonV310)) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index e9a9a27ea7..d9b8ac99b8 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -269,7 +269,7 @@ object Http4sDynamicEntity extends MdcLoggable { boxUser: Box[User], callContext: Option[CallContext]): Future[Box[Unit]] = if (!isPersonalEntity || !boxUser.exists(_.isConsentUser)) Future.successful(Full(())) else Helper.booleanToFuture( - s"$ConsentMyResourcesMissing personal_dynamic_entities entry needed: bank_id '${bankId.getOrElse("")}', entity_name '$entityName', action '$action'", + s"$ConsentMyResourcesMissing personal_dynamic_entities entry needed: bank_id '${bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)}', entity_name '$entityName', action '$action'", 403, cc = callContext) { callContext.flatMap(_.consentMyResources).exists(_.coversPersonalDynamicEntity(bankId, entityName, action)) } diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 34a5c568e9..e6272c47ea 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -124,10 +124,22 @@ case class Role(role_name: String, ) /** JWT claim: one personal dynamic entity the consent user may act on for the granting User. */ case class ConsentPersonalDynamicEntity(bank_id: String, entity_name: String, actions: List[String]) { - def bankIdOpt: Option[String] = Option(bank_id).filter(_.nonEmpty) + def bankIdOpt: Option[String] = ConsentPersonalDynamicEntity.bankIdOrNoneForSystem(bank_id) def covers(bankId: Option[String], entityName: String, action: String): Boolean = entity_name == entityName && bankIdOpt == bankId && actions.contains(action) } +object ConsentPersonalDynamicEntity { + /** + * This function turns the bank_id of a `my_resources` entry into the space the Dynamic Entity code + * asks about, where None is the system space. + * + * The system space is named `SYS` (Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID), as it is in stored + * records and in the Record Roles. The empty string named it before that, and consents written by + * existing clients still send it, so both mean the system space. Every other value is a bank id. + */ + def bankIdOrNoneForSystem(bankId: String): Option[String] = + Option(bankId).filter(b => b.nonEmpty && b != code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) +} /** JWT claim: the granting User's linked Customers at one Bank the consent user may act on. */ case class ConsentLinkedCustomers(bank_id: String, actions: List[String]) { def covers(bankId: String, action: String): Boolean = bank_id == bankId && actions.contains(action) @@ -1484,7 +1496,7 @@ object Consent extends MdcLoggable { badActions(where, entry.actions) }.flatten val problems: List[String] = customerProblems ++ myResources.toList.flatMap(_.personal_dynamic_entities.getOrElse(Nil)).flatMap { entry => - val bankId = Option(entry.bank_id).filter(_.nonEmpty) + val bankId = ConsentPersonalDynamicEntity.bankIdOrNoneForSystem(entry.bank_id) val where = s"personal_dynamic_entities entry (bank_id '${Option(entry.bank_id).getOrElse("")}', entity_name '${entry.entity_name}')" val definition = code.api.dynamic.entity.helper.DynamicEntityHelper.definitionOf(bankId, entry.entity_name) List( 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 ae2c3fcca8..86de33ade9 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -1716,7 +1716,7 @@ object Glossary extends MdcLoggable { || `entitlements` | Roles at a Bank or the system (granted) | the User holds the stored Entitlement; virtual Entitlements do not count | || `my_resources` | the User's own personal resources (owned), one typed list per kind, e.g. `personal_dynamic_entities` | the kind and instance exist; no Role, the User owns these rows | | -|`my_resources` is accepted by the Create Consent endpoint from v6.0.0 (older create-consent bodies are frozen). Example: `{"personal_dynamic_entities": [{"bank_id": "", "entity_name": "FooBar", "actions": ["read", "write"]}]}`. An entry names what the consent user may act on for the granting User; rows it writes belong to that User. Absent or empty means none, and `everything: true` does not include it. See ${getGlossaryItemLink("Dynamic-Entity-Access-Model")}. +|`my_resources` is accepted by the Create Consent endpoint from v6.0.0 (older create-consent bodies are frozen). Example: `{"personal_dynamic_entities": [{"bank_id": "SYS", "entity_name": "FooBar", "actions": ["read", "write"]}]}`. The `bank_id` of an entry is the space the entity lives in: `SYS` for a system-level entity, or the id of the Bank. An empty `bank_id` also means the system space. An entry names what the consent user may act on for the granting User; rows it writes belong to that User. Absent or empty means none, and `everything: true` does not include it. See ${getGlossaryItemLink("Dynamic-Entity-Access-Model")}. | | | diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 78fb549978..78a43091a7 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -3376,8 +3376,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { } -/** One personal dynamic entity the Consent may act on for the granting User: bank_id "" for a - * system-level entity; actions are "read" and/or "write". ideas/CONSENT_MY_RESOURCES.md */ +/** One personal dynamic entity the Consent may act on for the granting User: bank_id "SYS" for a + * system-level entity (the empty string is still accepted for it); actions are "read" and/or "write". + * ideas/CONSENT_MY_RESOURCES.md */ case class PostConsentPersonalDynamicEntityJson(bank_id: String, entity_name: String, actions: List[String]) /** The granting User's linked Customers at one Bank that the Consent may act on. bank_id is required: * a Customer belongs to a Bank, and naming it keeps the grant as narrow as the User meant it. diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala index 2862a3b671..f9b79a6e4a 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala @@ -81,8 +81,9 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { makeDeleteRequest((v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@ (user1)) } - private def personalEntity(name: String, actions: List[String]): JValue = - ("bank_id" -> "") ~ ("entity_name" -> name) ~ ("actions" -> actions) + /** A my_resources entry for a system-level entity. SYS names the system space; the empty string is the older form. */ + private def personalEntity(name: String, actions: List[String], bankId: String = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID): JValue = + ("bank_id" -> bankId) ~ ("entity_name" -> name) ~ ("actions" -> actions) private def consentBody(roleNames: List[String], myResources: Option[JValue]): JValue = { val base: JObject = @@ -185,6 +186,27 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { } finally deleteSystemEntity(dynamicEntityId) } + scenario("a consent naming the system space with the older empty bank_id still covers the entity", VersionOfApi, ConsentUserTag) { + val dynamicEntityId = createSystemEntity(entityName) + try { + val headers = consentHeaders(Nil, Some(("personal_dynamic_entities" -> List(personalEntity(entityName, List("read", "write"), bankId = ""))))) + val create = makePostRequest((dynamicEntity_Request / "my" / entityName).POST, write(record), headers) + create.code should equal(201) + val list = makeGetRequest((dynamicEntity_Request / "my" / entityName).GET, headers) + list.code should equal(200) + } finally deleteSystemEntity(dynamicEntityId) + } + + scenario("the missing-entry error names SYS as the bank_id of a system-level entity", VersionOfApi, ConsentUserTag) { + val dynamicEntityId = createSystemEntity(entityName) + try { + val headers = consentHeaders(Nil, Some(("personal_dynamic_entities" -> List(personalEntity(entityName, List("read")))))) + val create = makePostRequest((dynamicEntity_Request / "my" / entityName).POST, write(record), headers) + create.code should equal(403) + create.body.extract[ErrorMessage].message should include(s"bank_id '$DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID'") + } finally deleteSystemEntity(dynamicEntityId) + } + scenario("a consent listing the entity read-only may read but not write", VersionOfApi, ConsentUserTag) { val dynamicEntityId = createSystemEntity(entityName) try { From 7b9de1d0434f5e2dd77809d88e400e7135722979 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 25 Sep 2026 00:05:03 +0200 Subject: [PATCH 7/8] In v7.0.0 Major name changes to Dynamic Entity management (definition) paths and roles. Collapsing System and Bank level roles with bank_id OR SYS. (lets paths, less roles) --- DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md | 60 ++- .../docs/introductory_system_documentation.md | 14 +- .../dynamic/entity/Http4sDynamicEntity.scala | 98 +++-- .../entity/helper/DynamicEntityHelper.scala | 20 +- .../entity/helper/DynamicEntitySpace.scala | 79 ++++ .../main/scala/code/api/util/APIUtil.scala | 18 + .../main/scala/code/api/util/ApiRole.scala | 50 +-- .../scala/code/api/util/ConsentUtil.scala | 16 +- .../util/DiagnosticDynamicEntityCheck.scala | 8 +- .../main/scala/code/api/util/Glossary.scala | 31 +- .../util/http4s/ResourceDocMiddleware.scala | 14 +- .../code/api/util/migration/Migration.scala | 22 +- .../MigrationOfDynamicEntityRoleNames.scala | 122 ++++-- .../scala/code/api/v4_0_0/Http4s400.scala | 40 +- .../scala/code/api/v6_0_0/Http4s600.scala | 129 ++++--- .../scala/code/api/v7_0_0/Http4s700.scala | 7 + .../Http4s700DynamicEntityDefinitions.scala | 353 +++++++++++++++++ .../api/sweep/AnyBankScopeSweepTest.scala | 2 - .../AuthenticationTypeValidationTest.scala | 2 +- .../v4_0_0/DynamicCodeKillSwitchTest.scala | 5 +- .../code/api/v4_0_0/DynamicEntityTest.scala | 146 +++---- .../api/v4_0_0/DynamicIntegrationTest.scala | 2 +- .../api/v4_0_0/ForceErrorValidationTest.scala | 2 +- .../v4_0_0/GetScannedApiVersionsTest.scala | 7 +- .../api/v4_0_0/JsonSchemaValidationTest.scala | 2 +- .../v6_0_0/DynamicEntityAccessFlagsTest.scala | 4 +- .../v6_0_0/DynamicEntityAuthModeTest.scala | 4 +- .../v6_0_0/DynamicEntityConsentUserTest.scala | 18 +- .../v6_0_0/DynamicEntityFieldRolesTest.scala | 42 +-- ...DynamicEntityFilterAndBankAccessTest.scala | 8 +- .../DynamicEntityRecordIdLengthTest.scala | 5 +- ...DynamicEntityRoleRenameMigrationTest.scala | 62 ++- .../DynamicEntityRowLevelAccessTest.scala | 4 +- .../code/api/v6_0_0/DynamicEntityTest.scala | 51 +-- .../v7_0_0/DynamicEntityDefinitionTest.scala | 355 ++++++++++++++++++ release_notes.md | 54 +++ 36 files changed, 1454 insertions(+), 402 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala diff --git a/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md b/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md index 05de18d90c..9638603e67 100644 --- a/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md +++ b/DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md @@ -1,8 +1,8 @@ # Dynamic Entity space model — SYS is an ordinary bank id Written 2026-09-22. This is the only document for this work: no separate checklist. Track progress -here by marking items done in place. **Status on 2026-09-24: Phases 1, 2 and 3 are done, and phase 4 -for the Record Roles. Phase 5 is dropped. The Definition Roles join phase 6.** The decisions below are settled. Two pieces are in the tree: the storage half of +here by marking items done in place. **Status on 2026-09-24: Phases 1, 2, 3 and 4 are done (the +Definition Roles as part of 6b). Phase 5 is dropped. Phase 6 is done. Phases 7 and 8 remain.** The decisions below are settled. Two pieces are in the tree: the storage half of Phase 1, committed as `25f384baf`, where the two data tables adopted the sentinel and took their space-scoped unique index, and Phase 3, which refuses just-in-time entitlements in the system space. Everything else in this plan is still to write. @@ -262,7 +262,51 @@ may grant what has to reach all of them. Consumer Scopes mirror roles and carry a bank id (`MappedScopesProvider.scala:115`). If a scope can name `SYS`, it needs the same rule, or the application path bypasses the user path. -## Phase 6 — the v7.0.0 routes +## Phase 6 — the v7.0.0 routes — **done 2026-09-24 (6a, 6b, 6c)** + +**6a** (internal): the URL extractors collapsed to one branch per shape, and every call site asks +`DynamicEntityHelper.definitionOf(bankId, entityName)` instead of reading `definitionsMap` directly. + +**6b** (management routes, the Definition Roles, the space rule): + +* `DynamicEntitySpace` (`code.api.dynamic.entity.helper`) is the one place that converts between the + internal `Option` (None = system space) and the published bank id (`SYS`): `bankIdOrSystem` and + `bankIdOrNoneForSystem`, the latter also accepting the older empty string. The consent + `my_resources` check, the Record Role checks in `Http4sDynamicEntity` and the diagnostics all use it. +* `ResourceDoc.allowSystemSpace()` is the opt-in that lets `SYS` through `validateBank` with no bank + resolved; the middleware then checks the doc's Roles at `SYS` like any bank id. Only the v7.0.0 + definition docs opt in, so `/banks/SYS/…` still gives 404 everywhere else. +* v7.0.0 `/management/banks/BANK_ID/dynamic-entities` — list, create, update, delete (204), backup, + cascade delete (204) — lives in `Http4s700DynamicEntityDefinitions`, outside `Http4s700`'s + initialiser, and reuses the v6.0.0 and v4.0.0 functions. Every response carries `bank_id`. +* The Definition Roles merged as the table in phase 4 says, all `requiresBankId = true`; the two + `AnyBank` Roles are gone. The v4.0.0 and v6.0.0 `/management/system-dynamic-entities` endpoints keep + the Role in their doc with `disableAutoValidateRoles()` and check it in the handler at `SYS` + (`DynamicEntitySpace.requireRoleAtSystemSpace`), with the same 403 and message the middleware gives. +* `MigrationOfDynamicEntityRoleNames.renameDefinitionRolesEverywhere`, its own `runOnce` + (`renameDynamicEntityDefinitionRoles`), renames the stored Definition Roles and moves system level + ones to `SYS`; a Group the Record pass left at the empty bank id because it also held a Definition + Role now moves too. +* Defects fixed on the way, all left over from records moving to `SYS` in phase 1: the orphaned-records + diagnostic treated every system record as orphaned (so its cleanup deleted them); v6.0.0's system list + counted zero records; v6.0.0's backup checked and granted the Record Role at the empty bank id; and + v6.0.0's update reported an unknown id as a 400 InvalidJsonFormat instead of a 404. + +**6c** (data routes): `/obp/v7.0.0/banks/BANK_ID/dynamic-entities/...`, BANK_ID a bank's id or `SYS`, +followed by exactly what follows `/obp/dynamic-entity/` (`ENTITY[/ID]`, `my/...`, `public/...`, +`community/...`, `ENTITY/ID/access[/USER_ID]`). The `dynamic-entities` segment after the bank keeps an +entity named, say, `accounts` from colliding with a standard endpoint. `Http4sDynamicEntity.wrappedRoutesDynamicEntityV700` +rewrites the path to the unversioned shape and calls the same dispatcher, so both URL families run +the same checks on the same storage; it is wired into `Http4s700.wrappedRoutesV700Services` ahead of +the bridge to v6.0.0. The only behavioural difference: a v7.0.0 response always carries `bank_id` +(`SYS` included, via a request attribute read by `wrapBankId`), while the unversioned URLs keep +omitting it for the system space. The v7.0.0 definition responses' `_links` point at these URLs. +`definitionsMap` is now keyed by the published bank id (`SYS`), finishing step 1. + +Not done in 6c, and left for phase 8: the per-entity ResourceDocs are still generated for the +unversioned URLs only, so the API Explorer lists `/obp/dynamic-entity/...`; the v7.0.0 URLs are +documented in the Glossary. + Only now, with one storage convention, one role family and one grant rule, are the routes worth writing. `/obp/dynamic-entity/…` keeps serving unchanged throughout; the two read the same storage. @@ -275,6 +319,16 @@ writing. `/obp/dynamic-entity/…` keeps serving unchanged throughout; the two r instead of calling `getBank` directly, so the rule is stated once rather than repeated wherever a space is read. + **Superseded 2026-09-24: the template variable is `BANK_ID`, not `SPACE_ID`.** OBP writes + `banks/BANK_ID` everywhere, and a Dynamic Entity URL that said `banks/SPACE_ID` would mix two + vocabularies in one path. A consistent `spaces/SPACE_ID` alias for `banks/BANK_ID` may come later, + as its own step. Keeping `BANK_ID` also means the middleware's role check + (`ResourceDocMiddleware.authorizeRoles`, which reads `BANK_ID` from the path) checks a Definition + Role at `SYS` with no help from the handler. What `SYS` still needs is a way past `validateBank`, + which looks every `BANK_ID` up as a real bank; the proposal is an opt-in on the ResourceDoc, so + that only Dynamic Entity docs let `SYS` through and `/banks/SYS/accounts` still gives 404. The + paragraph below is the earlier reasoning, kept for the record. + Implement it **without touching `ResourceDocMiddleware`**. `validateBank` (`ResourceDocMiddleware.scala:614`) fires on the literal template variable `BANK_ID` and is shared by every endpoint in OBP, so relaxing it there would let `/banks/SYS/accounts` past bank validation diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index ae3291a86a..3d66491005 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -5472,14 +5472,12 @@ Roles follow a consistent naming pattern: - CanGetBankLevelDynamicEndpoints - CanUpdateBankLevelDynamicEndpoint - CanDeleteBankLevelDynamicEndpoint -- CanCreateSystemLevelDynamicEntity -- CanGetSystemLevelDynamicEntities -- CanUpdateSystemLevelDynamicEntity -- CanDeleteSystemLevelDynamicEntity -- CanCreateBankLevelDynamicEntity -- CanGetBankLevelDynamicEntities -- CanUpdateBankLevelDynamicEntity -- CanDeleteBankLevelDynamicEntity +- CanCreateDynamicEntityDefinition +- CanGetDynamicEntityDefinitions +- CanUpdateDynamicEntityDefinition +- CanDeleteDynamicEntityDefinition +- CanBackupDynamicEntityDefinition +- CanDeleteCascadeDynamicEntityDefinition - CanCreateDynamicResourceDoc - CanGetDynamicResourceDoc - CanGetAllDynamicResourceDocs diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index d9b8ac99b8..ed7228ecf5 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -29,7 +29,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.DynamicData.{DynamicData, DynamicDataProvider, DynamicDataAccessProvider, DynamicDataAccessPermission} import code.api.Constant.PARAM_LOCALE -import code.api.dynamic.entity.helper.{CommunityEntityName, DynamicEntityHelper, DynamicEntityInfo, EntityAccessName, EntityName, PublicEntityName} +import code.api.dynamic.entity.helper.{CommunityEntityName, DynamicEntityHelper, DynamicEntityInfo, DynamicEntitySpace, EntityAccessName, EntityName, PublicEntityName} import code.api.dynamic.entity.query.{FieldSpec, InMemoryQueryExecutor, JoinTargetInfo, QueryParamParser, QueryPlan, QueryPlanner} import code.api.dynamic.entity.projection.{IndexingCapabilities, PostgresProjectionBackend, ProjectionProvisioner} import cats.effect.unsafe.implicits.{global => ioRuntime} // aliased: avoids clashing with the EC `global` imported below @@ -163,22 +163,23 @@ object Http4sDynamicEntity extends MdcLoggable { private def listName(entityName: String): String = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "_list") private def singleName(entityName: String): String = StringHelpers.snakify(entityName).replaceFirst("[-_]*$", "") - private def wrapBankId(bankId: Option[String], result: JObject): JObject = - if (bankId.isDefined) (("bank_id" -> bankId.getOrElse("")): JObject) merge result else result + /** + * Set on a request that arrived at a v7.0.0 URL. From v7.0.0 every response names the space it + * came from, the system space included (as SYS); the unversioned `/obp/dynamic-entity/` URLs name + * only a bank, and keep doing so because their callers may rely on the field being absent. + */ + private val namesEverySpaceKey: org.typelevel.vault.Key[Boolean] = + org.typelevel.vault.Key.newKey[IO, Boolean].unsafeRunSync() + + private def wrapBankId(req: Request[IO], bankId: Option[String], result: JObject): JObject = + if (bankId.isDefined || req.attributes.lookup(namesEverySpaceKey).contains(true)) + (("bank_id" -> DynamicEntitySpace.bankIdOrSystem(bankId)): JObject) merge result + else result private def notFoundMsg(entityName: String, id: String, bankId: Option[String]): String = s"$EntityNotFoundByEntityId Entity: '$entityName', entityId: '$id'" + bankId.map(b => s", bank_id: '$b'").getOrElse("") /** Resolve bankId to a Bank (404 if missing) for bank-level entities; no-op otherwise. */ - /** - * The space an Entitlement for this request is held at: a real bank id, or the system space as - * DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. Every Dynamic Entity Role names a space and none is granted - * at the empty bank id, so a role check for a system level entity has to ask about SYS; asking - * about "" would look somewhere nobody grants. - */ - private def spaceOf(bankId: Option[String]): String = - bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) - private def bankCheck(bankId: Option[String], cc: Option[CallContext]): Future[(Any, Option[CallContext])] = if (bankId.isDefined) NewStyle.function.getBank(BankId(bankId.get), cc).map { case (b, c) => (b, c) } else Future.successful(("", cc)) @@ -269,7 +270,7 @@ object Http4sDynamicEntity extends MdcLoggable { boxUser: Box[User], callContext: Option[CallContext]): Future[Box[Unit]] = if (!isPersonalEntity || !boxUser.exists(_.isConsentUser)) Future.successful(Full(())) else Helper.booleanToFuture( - s"$ConsentMyResourcesMissing personal_dynamic_entities entry needed: bank_id '${bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)}', entity_name '$entityName', action '$action'", + s"$ConsentMyResourcesMissing personal_dynamic_entities entry needed: bank_id '${DynamicEntitySpace.bankIdOrSystem(bankId)}', entity_name '$entityName', action '$action'", 403, cc = callContext) { callContext.flatMap(_.consentMyResources).exists(_.coversPersonalDynamicEntity(bankId, entityName, action)) } @@ -281,7 +282,7 @@ object Http4sDynamicEntity extends MdcLoggable { /** The entity's role, checked per the entity's auth mode (entitlements, scopes, either or both). */ private def checkEntityRole(bankId: Option[String], entityName: String, boxUser: Box[User], role: ApiRole, callContext: Option[CallContext]): Future[Box[Unit]] = { - val bankIdStr = spaceOf(bankId) + val bankIdStr = DynamicEntitySpace.bankIdOrSystem(bankId) val userId = boxUser.map(_.userId).openOr("") val consumerId = code.api.util.APIUtil.getConsumerPrimaryKey(callContext) val errorMessage = UserHasMissingRoles + role.toString + s" at Bank($bankIdStr)" @@ -300,7 +301,7 @@ object Http4sDynamicEntity extends MdcLoggable { val writeRestricted = info.map(_.writeRestrictedFields).getOrElse(Nil).toSet val authMode = authModeOf(bankId, entityName) def has(role: code.api.util.ApiRole): Boolean = - code.api.util.APIUtil.handleAccessControlWithAuthMode(spaceOf(bankId), userId, consumerId, List(role), authMode) + code.api.util.APIUtil.handleAccessControlWithAuthMode(DynamicEntitySpace.bankIdOrSystem(bankId), userId, consumerId, List(role), authMode) touched.flatMap { f => if (writeRestricted.contains(f)) { val role = DynamicEntityInfo.fieldWriteRole(entityName, f, bankId, info.flatMap(_.explicitWriteRole(f))) @@ -335,7 +336,7 @@ object Http4sDynamicEntity extends MdcLoggable { val omit: Set[String] = readRestricted.filterNot { f => userIdOpt.exists { uid => val role = DynamicEntityInfo.fieldReadRole(entityName, f, bankId, info.flatMap(_.explicitReadRole(f))) - code.api.util.APIUtil.hasEntitlement(spaceOf(bankId), uid, role) + code.api.util.APIUtil.hasEntitlement(DynamicEntitySpace.bankIdOrSystem(bankId), uid, role) } }.toSet if (omit.isEmpty) value else omitFields(value, omit) @@ -419,7 +420,7 @@ object Http4sDynamicEntity extends MdcLoggable { val readableRows = dataVend.getAllCommunity(bankId, entityName).filter(_.dynamicDataId.exists(readable.contains)) val readableJson: JArray = JArray(readableRows.map(r => parse(r.dataJson))) val filtered = filterDynamicObjects(readableJson, queryParams(req)) - wrapBankId(bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, Some(u.userId)))) + wrapBankId(req, bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, Some(u.userId)))) } else { val box: Box[JValue] = dataVend.getCommunity(bankId, entityName, id).map(it => parse(it.dataJson)) for { @@ -429,7 +430,7 @@ object Http4sDynamicEntity extends MdcLoggable { } } yield { val singleObject: JValue = unboxResult(box, entityName) - wrapBankId(bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, Some(u.userId)))) + wrapBankId(req, bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, Some(u.userId)))) } } } yield result @@ -452,7 +453,7 @@ object Http4sDynamicEntity extends MdcLoggable { updateJson = preserveRestrictedOnPut(json.asInstanceOf[JObject], existing, writeRestrictedFieldsOf(bankId, entityName)) box: Box[JValue] = dataVend.updateCommunity(bankId, entityName, updateJson, id).map(it => parse(it.dataJson)) singleObject: JValue = unboxResult(box, entityName) - } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } yield wrapBankId(req, bankId, (singleName(entityName) -> singleObject)) } private def rowLevelPatch(req: Request[IO], cc: CallContext, bankId: Option[String], entityName: String, id: String): Future[JValue] = { @@ -475,7 +476,7 @@ object Http4sDynamicEntity extends MdcLoggable { mergedJson = mergePatch(DynamicEntityHelper.definitionOf(bankId, entityName), existing, bodyObj) box: Box[JValue] = dataVend.updateCommunity(bankId, entityName, mergedJson, id).map(it => parse(it.dataJson)) singleObject: JValue = unboxResult(box, entityName) - } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } yield wrapBankId(req, bankId, (singleName(entityName) -> singleObject)) } private def rowLevelDelete(req: Request[IO], cc: CallContext, bankId: Option[String], entityName: String, id: String): Future[JValue] = { @@ -527,7 +528,7 @@ object Http4sDynamicEntity extends MdcLoggable { _ <- Helper.booleanToFuture(RowLevelAccessNotEnabled, 400, cc = callContext2) { isRowLevel(bankId, entityName) } _ <- Helper.booleanToFuture(s"$UserHasMissingRoles grant access on this row", 403, cc = callContext2) { aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Grant) || - hasEntitlement(spaceOf(bankId), u.userId, DynamicEntityInfo.canGrantRowAccessRole(entityName, bankId)) + hasEntitlement(DynamicEntitySpace.bankIdOrSystem(bankId), u.userId, DynamicEntityInfo.canGrantRowAccessRole(entityName, bankId)) } } yield (u, callContext2) } @@ -614,10 +615,10 @@ object Http4sDynamicEntity extends MdcLoggable { val legacyFiltered = filterDynamicObjects(resultList, queryParams(req)) applyQueryPlan(legacyFiltered, queryPlan, deIndexedFields(bankId, entityName)) } - wrapBankId(bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, userIdOpt))) + wrapBankId(req, bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, userIdOpt))) } else { val singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) - wrapBankId(bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, userIdOpt))) + wrapBankId(req, bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, userIdOpt))) } } } @@ -649,7 +650,7 @@ object Http4sDynamicEntity extends MdcLoggable { userIdOpt.foreach(uid => aclVend.grant(bankId, entityName, rid, uid, canRead = true, canUpdate = true, canDelete = true, canGrant = true, grantedBy = uid)) case _ => } - } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } yield wrapBankId(req, bankId, (singleName(entityName) -> singleObject)) } private def genericPut(req: Request[IO], bankId: Option[String], entityName: String, id: String, isPersonalEntity: Boolean): IO[Response[IO]] = @@ -678,7 +679,7 @@ object Http4sDynamicEntity extends MdcLoggable { updateJson = preserveRestrictedOnPut(json.asInstanceOf[JObject], existing.asInstanceOf[Box[JValue]], writeRestrictedFieldsOf(bankId, entityName)) (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(updateJson), Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box, entityName) - } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } yield wrapBankId(req, bankId, (singleName(entityName) -> singleObject)) } private def genericPatch(req: Request[IO], bankId: Option[String], entityName: String, id: String, isPersonalEntity: Boolean): IO[Response[IO]] = @@ -712,7 +713,7 @@ object Http4sDynamicEntity extends MdcLoggable { mergedJson = mergePatch(DynamicEntityHelper.definitionOf(bankId, entityName), existing.asInstanceOf[Box[JValue]], bodyObj) (box: Box[JValue], _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(mergedJson), Some(id), bankId, None, userIdOpt, isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box, entityName) - } yield wrapBankId(bankId, (singleName(entityName) -> singleObject)) + } yield wrapBankId(req, bankId, (singleName(entityName) -> singleObject)) } private def genericDelete(req: Request[IO], bankId: Option[String], entityName: String, id: String, isPersonalEntity: Boolean): IO[Response[IO]] = @@ -764,10 +765,10 @@ object Http4sDynamicEntity extends MdcLoggable { val resultList: JArray = unboxResult(box.asInstanceOf[Box[JArray]], entityName) val legacyFiltered = filterDynamicObjects(resultList, queryParams(req)) val filtered = applyQueryPlan(legacyFiltered, queryPlan, deIndexedFields(bankId, entityName)) - wrapBankId(bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, None))) + wrapBankId(req, bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, None))) } else { val singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) - wrapBankId(bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, None))) + wrapBankId(req, bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, None))) } } } @@ -784,7 +785,7 @@ object Http4sDynamicEntity extends MdcLoggable { _ <- failIf(beforeIntercept(callContext0, operationId), Some(callContext0)) (Full(u), callContext) <- authenticatedAccess(callContext0) (_, callContext) <- bankCheck(bankId, callContext) - _ <- NewStyle.function.hasEntitlement(spaceOf(bankId), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) + _ <- NewStyle.function.hasEntitlement(DynamicEntitySpace.bankIdOrSystem(bankId), u.userId, DynamicEntityInfo.canGetRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) queryPlan <- if (isGetAll) buildQueryPlan(req, bankId, entityName, callContext) else Future.successful(QueryPlan.empty) // Community reads are in-memory only; joins require the projection backend. @@ -796,14 +797,14 @@ object Http4sDynamicEntity extends MdcLoggable { val resultArray = JArray(resultList) val legacyFiltered = filterDynamicObjects(resultArray, queryParams(req)) val filtered = applyQueryPlan(legacyFiltered, queryPlan, deIndexedFields(bankId, entityName)) - wrapBankId(bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, Some(u.userId)))) + wrapBankId(req, bankId, (listName(entityName) -> applyReadRestrictions(filtered, bankId, entityName, Some(u.userId)))) } else { val singleResult = DynamicDataProvider.connectorMethodProvider.vend.getCommunity(bankId, entityName, id) val singleObject: JValue = singleResult match { case Full(data) => com.openbankproject.commons.util.JsonAliases.parse(data.dataJson) case _ => throw new RuntimeException(notFoundMsg(entityName, id, bankId)) } - wrapBankId(bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, Some(u.userId)))) + wrapBankId(req, bankId, (singleName(entityName) -> applyReadRestrictions(singleObject, bankId, entityName, Some(u.userId)))) } } } @@ -815,7 +816,7 @@ object Http4sDynamicEntity extends MdcLoggable { * extractors the Lift dispatcher used. Order public -> community -> generic mirrors * OBPAPIDynamicEntity.routes. No match -> OptionT.none (request falls through the chain). */ - private def dispatch(req: Request[IO], rest: List[String]): OptionT[IO, Response[IO]] = { + private def dispatch(req: Request[IO], rest: List[String], apiVersion: String): OptionT[IO, Response[IO]] = { val handlerOpt: Option[Request[IO] => IO[Response[IO]]] = (req.method, rest) match { case (Method.GET, PublicEntityName(bankId, entityName, id)) => Some(r => publicGet(r, bankId, entityName, id)) @@ -844,7 +845,7 @@ object Http4sDynamicEntity extends MdcLoggable { case None => OptionT.none[IO, Response[IO]] case Some(handler) => OptionT.liftF { - Http4sCallContextBuilder.fromRequest(req, apiVersionString).flatMap { cc => + Http4sCallContextBuilder.fromRequest(req, apiVersion).flatMap { cc => val reqWithCc = req.withAttribute(Http4sRequestAttributes.callContextKey, cc) val io = handler(reqWithCc) if (req.method == Method.GET || req.method == Method.HEAD) io @@ -859,7 +860,36 @@ object Http4sDynamicEntity extends MdcLoggable { Kleisli[HttpF, Request[IO], Response[IO]] { (req: Request[IO]) => req.uri.path.segments.map(_.encoded).toList match { case standard :: version :: rest if standard == apiStandard && version == apiVersionString => - dispatch(req, rest) + dispatch(req, rest, apiVersionString) + case _ => + OptionT.none[IO, Response[IO]] + } + } + + private val v700String = com.openbankproject.commons.util.ApiVersion.v7_0_0.toString // "v7.0.0" + + /** + * The v7.0.0 data URLs: `/obp/v7.0.0/banks/BANK_ID/dynamic-entities/...`, where BANK_ID is a bank's + * id or SYS for the system space, and what follows is the same as after `/obp/dynamic-entity/` + * (`ENTITY[/ID]`, `my/ENTITY[/ID]`, `public/...`, `community/...`, `ENTITY/ID/access[/USER_ID]`). + * + * One URL shape serves every space, so the space comes first and is always named. The path is + * rewritten into the unversioned shape and handed to the same dispatcher, so both URL families run + * the same checks against the same storage; the only difference is that a v7.0.0 response names its + * space even when it is SYS. The `dynamic-entities` segment keeps an entity called, say, `accounts` + * from colliding with `/banks/BANK_ID/accounts`. Wired into Http4s700 ahead of its bridge to v6.0.0. + * See DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6. + */ + lazy val wrappedRoutesDynamicEntityV700: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { (req: Request[IO]) => + req.uri.path.segments.map(_.encoded).toList match { + case standard :: version :: "banks" :: bankIdInUrl :: "dynamic-entities" :: rest + if standard == apiStandard && version == v700String => + val unversionedRest = DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl) match { + case Some(bankId) => "banks" :: bankId :: rest + case None => rest + } + dispatch(req.withAttribute(namesEverySpaceKey, true), unversionedRest, v700String) case _ => OptionT.none[IO, Response[IO]] } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index 859bbd96b3..c660d97b5c 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -64,16 +64,11 @@ private object SpaceSegments { /** Does a definition for this entity exist in this space? */ private object DefinitionIn { def apply(space: Option[String], entityName: String): Boolean = - DynamicEntityHelper.definitionsMap.exists { - case ((mapSpace, mapName), info) => mapSpace == space && mapName == entityName && info.bankId == space - } + DynamicEntityHelper.definitionOf(space, entityName).isDefined /** As above, and the definition must also satisfy `predicate` — a flag such as hasPublicAccess. */ def apply(space: Option[String], entityName: String, predicate: DynamicEntityInfo => Boolean): Boolean = - DynamicEntityHelper.definitionsMap.exists { - case ((mapSpace, mapName), info) => - mapSpace == space && mapName == entityName && info.bankId == space && predicate(info) - } + DynamicEntityHelper.definitionOf(space, entityName).exists(predicate) } object EntityName { @@ -198,19 +193,18 @@ object DynamicEntityHelper { } private val implementedInApiVersion = ApiVersion.v4_0_0 - // (Some(BankId), EntityName, DynamicEntityInfo) - def definitionsMap: Map[(Option[String], String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((it.bankId, it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess, it.authMode))).toMap + // Keyed by (bank id as published, entity name): SYS for the system space, never None or "". + def definitionsMap: Map[(String, String), DynamicEntityInfo] = NewStyle.function.getDynamicEntities(None, true).map(it => ((DynamicEntitySpace.bankIdOrSystem(it.bankId), it.entityName), DynamicEntityInfo(it.metadataJson, it.entityName, it.bankId, it.hasPersonalEntity, it.hasPublicAccess, it.hasCommunityAccess, it.personalRequiresRole, it.useRowLevelAccess, it.authMode))).toMap /** * The definition of one entity in one space, or None when that space holds no such entity. * * Callers ask by space and name rather than reaching into [[definitionsMap]], so that the shape of - * the key stays an implementation detail: it carries an Option today and will carry the bank id as - * a plain string once the system space is `SYS` everywhere. `bankId` is the space, and None is the - * system space. + * the key stays an implementation detail. `bankId` is the space, with None for the system space; the + * map itself is keyed by the published form, SYS. */ def definitionOf(bankId: Option[String], entityName: String): Option[DynamicEntityInfo] = - definitionsMap.get((bankId, entityName)) + definitionsMap.get((DynamicEntitySpace.bankIdOrSystem(bankId), entityName)) def dynamicEntityRoles: List[String] = NewStyle.function.getDynamicEntities(None, true).flatMap { dEntity => val baseRoles = DynamicEntityInfo.roleNames(dEntity.entityName, dEntity.bankId) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala new file mode 100644 index 0000000000..ede415eeb8 --- /dev/null +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntitySpace.scala @@ -0,0 +1,79 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.dynamic.entity.helper + +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID +import code.api.util.APIUtil.{EndpointAuthMode, UserOnly} +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.util.{APIUtil, ApiRole, CallContext} +import code.util.Helper +import net.liftweb.common.Box + +import scala.concurrent.Future + +/** + * This object holds the rules for naming a Dynamic Entity's space, in one place. + * + * Every Dynamic Entity lives in a space: either a bank, or the system space, whose bank id is + * DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID (SYS). Inside the Dynamic Entity code a space is still an + * Option, with None for the system space, while URLs, Roles, consents and storage all say SYS. The + * two conversions below are the only places that translate between those forms, so that the rule is + * stated once rather than wherever a space is read. See DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md. + */ +object DynamicEntitySpace { + + /** + * The bank id as the Dynamic Entity code asks about it: None for the system space. + * + * Both SYS and the empty string name the system space. The empty string is the older form, which + * existing callers (consents written before SYS was published, for instance) still send. + */ + def bankIdOrNoneForSystem(bankId: String): Option[String] = + Option(bankId).filter(b => b.nonEmpty && b != DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + + /** The bank id as URLs, Roles and storage write it: SYS for the system space. */ + def bankIdOrSystem(bankId: Option[String]): String = + bankId.filter(_.nonEmpty).getOrElse(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + + /** + * Refuse with 403 unless the caller holds a Definition Role at the system space. + * + * The v4.0.0 and v6.0.0 `/management/system-dynamic-entities` endpoints have no bank id in their + * URL, so the middleware would check their Role at the empty bank id, where no Definition Role is + * granted any more. Those endpoints declare the Role with disableAutoValidateRoles(), so it still + * shows in the catalogue, and call this instead. It answers exactly as the middleware would have: + * the same access rule, the same 403 and the same message. `authMode` is the endpoint's own, as its + * ResourceDoc declares it. + */ + def requireRoleAtSystemSpace(role: ApiRole, cc: CallContext, authMode: EndpointAuthMode = UserOnly): Future[Box[Unit]] = { + val userId = cc.user.map(_.userId).openOr("") + Helper.booleanToFuture(UserHasMissingRoles + role.toString, 403, Some(cc)) { + APIUtil.handleAccessControlWithAuthMode( + DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, userId, APIUtil.getConsumerPrimaryKey(Some(cc)), List(role), authMode) + } + } +} diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index bac46c035c..b2d79e2b0c 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1764,6 +1764,24 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ * requests. */ def isAutoValidateRoles: Boolean = _autoValidateRoles + + private var _allowsSystemSpace = false + + /** + * Let the system space's bank id, DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID (SYS), stand in for BANK_ID. + * + * The middleware looks every BANK_ID up as a real bank and answers 404 when there is none, and no + * bank is called SYS. A Dynamic Entity lives in a space, which is either a bank or the system space, + * so its endpoints need SYS to get through; every other endpoint needs a real bank and must keep + * the 404. This is the opt-in that tells them apart. With it, a request naming SYS reaches the + * handler with no bank resolved, and the declared Roles are checked at SYS. + */ + def allowSystemSpace(): ResourceDoc = { + _allowsSystemSpace = true + this + } + + def allowsSystemSpace: Boolean = _allowsSystemSpace private var _autoValidateAuthenticate = true def disableAutoValidateAuthenticate(): ResourceDoc = { _autoValidateAuthenticate = false diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 670b3744ea..aaa383e3c7 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -957,44 +957,28 @@ object ApiRole extends MdcLoggable{ case class CanDeleteSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteSignatoryPanel = CanDeleteSignatoryPanel() - case class CanGetSystemLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetSystemLevelDynamicEntities = CanGetSystemLevelDynamicEntities() + // The Definition Roles gate a Dynamic Entity's definition (its schema and flags), as opposed to the + // Record Roles (CanCreateDynamicEntityRecord_ and friends), which gate its rows. Each one is + // granted at a bank id or at DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID (SYS) for the system space, and none + // reaches every bank at once. They replace the System, BankLevel and AnyBankLevel variants; stored + // grants of those were renamed by MigrationOfDynamicEntityRoleNames.renameDefinitionRolesEverywhere. + case class CanCreateDynamicEntityDefinition(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateDynamicEntityDefinition = CanCreateDynamicEntityDefinition() - case class CanCreateSystemLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole - lazy val canCreateSystemLevelDynamicEntity = CanCreateSystemLevelDynamicEntity() + case class CanUpdateDynamicEntityDefinition(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateDynamicEntityDefinition = CanUpdateDynamicEntityDefinition() - case class CanCreateBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole - lazy val canCreateBankLevelDynamicEntity = CanCreateBankLevelDynamicEntity() + case class CanDeleteDynamicEntityDefinition(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteDynamicEntityDefinition = CanDeleteDynamicEntityDefinition() - case class CanCreateAnyBankLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole - lazy val canCreateAnyBankLevelDynamicEntity = CanCreateAnyBankLevelDynamicEntity() + case class CanGetDynamicEntityDefinitions(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetDynamicEntityDefinitions = CanGetDynamicEntityDefinitions() - case class CanUpdateSystemLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole - lazy val canUpdateSystemDynamicEntity = CanUpdateSystemLevelDynamicEntity() + case class CanDeleteCascadeDynamicEntityDefinition(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteCascadeDynamicEntityDefinition = CanDeleteCascadeDynamicEntityDefinition() - case class CanUpdateBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole - lazy val canUpdateBankLevelDynamicEntity = CanUpdateBankLevelDynamicEntity() - - case class CanDeleteSystemLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole - lazy val canDeleteSystemLevelDynamicEntity = CanDeleteSystemLevelDynamicEntity() - - case class CanDeleteCascadeSystemDynamicEntity(requiresBankId: Boolean = false) extends ApiRole - lazy val canDeleteCascadeSystemDynamicEntity = CanDeleteCascadeSystemDynamicEntity() - - case class CanBackupSystemDynamicEntity(requiresBankId: Boolean = false) extends ApiRole - lazy val canBackupSystemDynamicEntity = CanBackupSystemDynamicEntity() - - case class CanBackupBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole - lazy val canBackupBankLevelDynamicEntity = CanBackupBankLevelDynamicEntity() - - case class CanDeleteBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole - lazy val canDeleteBankLevelDynamicEntity = CanDeleteBankLevelDynamicEntity() - - case class CanGetBankLevelDynamicEntities(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetBankLevelDynamicEntities = CanGetBankLevelDynamicEntities() - - case class CanGetAnyBankLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetAnyBankLevelDynamicEntities = CanGetAnyBankLevelDynamicEntities() + case class CanBackupDynamicEntityDefinition(requiresBankId: Boolean = true) extends ApiRole + lazy val canBackupDynamicEntityDefinition = CanBackupDynamicEntityDefinition() case class CanGetDynamicEntityDiagnostics(requiresBankId: Boolean = false) extends ApiRole lazy val canGetDynamicEntityDiagnostics = CanGetDynamicEntityDiagnostics() diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index e6272c47ea..92e057c7d4 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -124,22 +124,10 @@ case class Role(role_name: String, ) /** JWT claim: one personal dynamic entity the consent user may act on for the granting User. */ case class ConsentPersonalDynamicEntity(bank_id: String, entity_name: String, actions: List[String]) { - def bankIdOpt: Option[String] = ConsentPersonalDynamicEntity.bankIdOrNoneForSystem(bank_id) + def bankIdOpt: Option[String] = code.api.dynamic.entity.helper.DynamicEntitySpace.bankIdOrNoneForSystem(bank_id) def covers(bankId: Option[String], entityName: String, action: String): Boolean = entity_name == entityName && bankIdOpt == bankId && actions.contains(action) } -object ConsentPersonalDynamicEntity { - /** - * This function turns the bank_id of a `my_resources` entry into the space the Dynamic Entity code - * asks about, where None is the system space. - * - * The system space is named `SYS` (Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID), as it is in stored - * records and in the Record Roles. The empty string named it before that, and consents written by - * existing clients still send it, so both mean the system space. Every other value is a bank id. - */ - def bankIdOrNoneForSystem(bankId: String): Option[String] = - Option(bankId).filter(b => b.nonEmpty && b != code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) -} /** JWT claim: the granting User's linked Customers at one Bank the consent user may act on. */ case class ConsentLinkedCustomers(bank_id: String, actions: List[String]) { def covers(bankId: String, action: String): Boolean = bank_id == bankId && actions.contains(action) @@ -1496,7 +1484,7 @@ object Consent extends MdcLoggable { badActions(where, entry.actions) }.flatten val problems: List[String] = customerProblems ++ myResources.toList.flatMap(_.personal_dynamic_entities.getOrElse(Nil)).flatMap { entry => - val bankId = ConsentPersonalDynamicEntity.bankIdOrNoneForSystem(entry.bank_id) + val bankId = code.api.dynamic.entity.helper.DynamicEntitySpace.bankIdOrNoneForSystem(entry.bank_id) val where = s"personal_dynamic_entities entry (bank_id '${Option(entry.bank_id).getOrElse("")}', entity_name '${entry.entity_name}')" val definition = code.api.dynamic.entity.helper.DynamicEntityHelper.definitionOf(bankId, entry.entity_name) List( diff --git a/obp-api/src/main/scala/code/api/util/DiagnosticDynamicEntityCheck.scala b/obp-api/src/main/scala/code/api/util/DiagnosticDynamicEntityCheck.scala index adbc71466a..9f5caa3a03 100644 --- a/obp-api/src/main/scala/code/api/util/DiagnosticDynamicEntityCheck.scala +++ b/obp-api/src/main/scala/code/api/util/DiagnosticDynamicEntityCheck.scala @@ -193,10 +193,12 @@ object DiagnosticDynamicEntityCheck { (entity.entityName, entity.bankId) }.toSet - // Get all data records and group by (entityName, bankId) + // Get all data records and group by (entityName, bankId). A definition names the system space + // as None, while its records store SYS in the bank id column, so the column is read through the + // same conversion; reading it raw would report every system level record as orphaned. val allDataRecords = DynamicData.findAll() val grouped = allDataRecords.groupBy { record => - (record.dynamicEntityName, Option(record.BankId.get).filter(_.nonEmpty)) + (record.dynamicEntityName, code.api.dynamic.entity.helper.DynamicEntitySpace.bankIdOrNoneForSystem(record.BankId.get)) } // Find groups that have no matching definition @@ -204,7 +206,7 @@ object DiagnosticDynamicEntityCheck { if (!definedEntities.contains((entityName, bankId))) { Some(OrphanedEntityInfo( entityName = entityName, - bankId = bankId.getOrElse(""), + bankId = code.api.dynamic.entity.helper.DynamicEntitySpace.bankIdOrSystem(bankId), recordCount = records.size.toLong )) } else None 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 86de33ade9..1687c912a4 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3692,12 +3692,34 @@ object Glossary extends MdcLoggable { | |**Management endpoints:** | +|From v7.0.0 one set of URLs manages the definitions in every space. BANK_ID is a bank's id, or `SYS` for the system space, and each Role is checked at that BANK_ID: +| +|* GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities - List the definitions in the space, with record counts (CanGetDynamicEntityDefinitions) +|* POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities - Create a definition (CanCreateDynamicEntityDefinition) +|* PUT /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition (CanUpdateDynamicEntityDefinition) +|* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID - Delete a definition that has no records (CanDeleteDynamicEntityDefinition) +|* POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup - Copy a definition and its records to a `_BAK` entity (CanBackupDynamicEntityDefinition) +|* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID - Delete a definition and its records, after copying both to a `ZZ_BAK_` entity (CanDeleteCascadeDynamicEntityDefinition) +| +|**Record endpoints from v7.0.0:** +| +|A Dynamic Entity's records are served at `/obp/v7.0.0/banks/BANK_ID/dynamic-entities/...`, with BANK_ID a bank's id or `SYS` for the system space. What follows `dynamic-entities/` is the same as after `/obp/dynamic-entity/`: +| +|* ENTITY_NAME and ENTITY_NAME/RECORD_ID - list, create, read, update (PUT and PATCH) and delete +|* my/ENTITY_NAME[/RECORD_ID] - the caller's own records +|* public/ENTITY_NAME[/RECORD_ID] and community/ENTITY_NAME[/RECORD_ID] - the read-only forms +|* ENTITY_NAME/RECORD_ID/access[/USER_ID] - the access list of a row-level entity +| +|Every v7.0.0 response carries `bank_id`, `SYS` included. The unversioned `/obp/dynamic-entity/[banks/BANK_ID/]...` URLs serve the same records with the same checks, and keep omitting `bank_id` for the system space. +| +|Earlier versions keep their separate management URLs for the system space; their Roles are the same ones, granted at `SYS`: +| |* POST /management/system-dynamic-entities - Create system level entity |* POST /management/banks/BANK_ID/dynamic-entities - Create bank level entity |* GET /management/system-dynamic-entities - List all system level entities |* GET /management/banks/BANK_ID/dynamic-entities - List bank level entities |* PUT /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Update entity definition -|* DELETE /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Delete entity (and all its data) +|* DELETE /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Delete an entity that has no records | |**Discovering Dynamic Entity Endpoints (for application developers):** | @@ -3720,8 +3742,8 @@ object Glossary extends MdcLoggable { | |**Required roles to manage Dynamic Entities:** | -|* CanCreateSystemLevelDynamicEntity -|* CanCreateBankLevelDynamicEntity +|* CanCreateDynamicEntityDefinition, granted at the bank id of the space: `SYS` for the system space, or a bank's id +|* CanUpdateDynamicEntityDefinition, CanDeleteDynamicEntityDefinition, CanGetDynamicEntityDefinitions, CanBackupDynamicEntityDefinition and CanDeleteCascadeDynamicEntityDefinition, granted the same way | |**Use cases:** | @@ -3983,8 +4005,7 @@ object Glossary extends MdcLoggable { | |**Required roles:** | -|* CanCreateSystemLevelDynamicEntity - To create system level dynamic entities -|* CanCreateBankLevelDynamicEntity - To create bank level dynamic entities +|* CanCreateDynamicEntityDefinition - To create dynamic entities. Granted at `SYS` it covers the system space; granted at a bank's id it covers that bank. | |For general information about Dynamic Entities, see ${getGlossaryItemLink("Dynamic-Entities")} | diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 651897c93d..62e89a37ab 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -322,7 +322,7 @@ object ResourceDocMiddleware extends MdcLoggable { context <- validateDuplicateQueryParams(cc, initialContext) context <- authenticate(req, resourceDoc, context) context <- refuseUnresolvedUKConsent(resourceDoc, context) - context <- validateBank(pathParams, context) + context <- validateBank(resourceDoc, pathParams, context) context <- authorizeRoles(resourceDoc, pathParams, context) context <- validateAccount(pathParams, context) context <- validateView(pathParams, context) @@ -610,10 +610,18 @@ object ResourceDocMiddleware extends MdcLoggable { success(ctx) } - /** Bank validation: checks BANK_ID and fetches bank */ - private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { + /** + * Bank validation: checks BANK_ID and fetches the bank. + * + * The one exception is the system space. A ResourceDoc that declares allowSystemSpace() accepts + * DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID (SYS) as BANK_ID, and the request goes on with no bank + * resolved. Every other endpoint still answers 404 for SYS, because no bank has that id. + */ + private def validateBank(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { pathParams.get("BANK_ID") match { + case Some(bankId) if bankId == code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID && resourceDoc.allowsSystemSpace => + DSL.success(ctx) case Some(bankId) => EitherT( IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext)))) 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 215e5014dd..d45dcbbb84 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 @@ -192,6 +192,7 @@ object Migration extends MdcLoggable { alterDynamicResourceDocTextFieldsLength() alterDynamicDataIdLength() renameDynamicEntityRoles() + renameDynamicEntityDefinitionRoles() } /** @@ -912,8 +913,8 @@ object Migration extends MdcLoggable { * Move every stored Dynamic Entity Role onto its new name, and onto the system space where it used * to sit at the empty bank id. * - * The Roles were renamed twice over: the ones gating a definition lost their System / BankLevel - * split, and the ones gating records gained the word Record and lost their System twin. A renamed + * This is the Record pass: the Roles gating records gained the word Record and lost their System + * twin. The Roles gating a definition follow in [[renameDynamicEntityDefinitionRoles]]. A renamed * Role is different from a narrowed one — the old name no longer exists, so an existing grant * authorises nothing rather than authorising less — and the mapping is exactly one-to-one, which * is what makes it safe to do here instead of asking every operator to re-grant. The work, and @@ -926,6 +927,23 @@ object Migration extends MdcLoggable { } } + /** + * Move every stored Dynamic Entity Definition Role onto its merged name, and onto the system space + * where it sat at the empty bank id: `CanCreateSystemLevelDynamicEntity` at the empty bank id becomes + * `CanCreateDynamicEntityDefinition` at SYS, `CanCreateBankLevelDynamicEntity` at a bank keeps its bank. + * + * This is a separate runOnce from [[renameDynamicEntityRoles]] because it ships later: the merged + * Roles are bank scoped, and a system level grant of one can only be checked where a URL names the + * space, which the v7.0.0 management endpoints are the first to do. The work is in + * [[MigrationOfDynamicEntityRoleNames.renameDefinitionRolesEverywhere]]. + */ + private def renameDynamicEntityDefinitionRoles(): Boolean = { + val name = nameOf(renameDynamicEntityDefinitionRoles) + runOnce(name) { + MigrationOfDynamicEntityRoleNames.renameDefinitionRolesEverywhere(name) + } + } + private def alterDynamicDataIdLength(): Boolean = { val name = nameOf(alterDynamicDataIdLength) runOnce(name) { diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala index 3fc3cdb148..cf380a53cb 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicEntityRoleNames.scala @@ -38,21 +38,24 @@ import code.scope.MappedScope * Rename the Dynamic Entity Roles wherever a Role name is stored, and move the ones that were system * level onto the system space. * - * The Roles that gate an entity's records gained the word Record and lost their System twin, so - * `CanCreateDynamicEntity_SystemCountry` and `CanCreateDynamicEntity_Country` are both now - * `CanCreateDynamicEntityRecord_Country`. The name no longer says which space it applies to; the - * Entitlement's bank id does, and the system space is DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than - * the empty string it used to be. + * This happens in two passes, each registered as its own runOnce migration, because the two Role + * families moved at different times. * - * The Roles that gate a **definition** are deliberately untouched here. Merging their System and - * BankLevel variants into one name means choosing one `requiresBankId`, and choosing `false` — which - * the system management endpoints need, because their URLs carry no space for the middleware to read - * — would widen the bank level Role into one grant that authorises every bank. They are renamed and - * re-scoped in the same change that gives those endpoints a space in their URL; see - * DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6. + * The **Record** pass ([[renameEverywhere]]). The Roles that gate an entity's records gained the word + * Record and lost their System twin, so `CanCreateDynamicEntity_SystemCountry` and + * `CanCreateDynamicEntity_Country` are both now `CanCreateDynamicEntityRecord_Country`. The name no + * longer says which space it applies to; the Entitlement's bank id does, and the system space is + * DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than the empty string it used to be. + * + * The **Definition** pass ([[renameDefinitionRolesEverywhere]]). The Roles that gate a definition + * lost their System and BankLevel split: `CanCreateSystemLevelDynamicEntity` and + * `CanCreateBankLevelDynamicEntity` are both now `CanCreateDynamicEntityDefinition`, and so on for + * update, delete, get, backup and cascade delete. It waited for the v7.0.0 management endpoints, + * which name the space in their URL, because until then a system level Definition Role could only be + * checked at the empty bank id. See DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6. * * Unlike a Role that merely narrows, a renamed Role leaves an existing grant meaningless rather than - * weaker: the old name no longer exists, so nothing reads the row. The rename is also exactly + * weaker: the old name no longer exists, so nothing reads the row. Each rename is also exactly * one-to-one, with no fan-out per bank, which is what makes this worth migrating rather than asking * every operator to re-grant by hand. * @@ -90,9 +93,24 @@ object MigrationOfDynamicEntityRoleNames { "CanGetDynamicEntityField_" -> "CanGetDynamicEntityField_" ) + /** The Definition Roles: every old name -> the one Role that replaces it. */ + private val definitionRoleRenames: Map[String, String] = Map( + "CanCreateSystemLevelDynamicEntity" -> "CanCreateDynamicEntityDefinition", + "CanCreateBankLevelDynamicEntity" -> "CanCreateDynamicEntityDefinition", + "CanUpdateSystemLevelDynamicEntity" -> "CanUpdateDynamicEntityDefinition", + "CanUpdateBankLevelDynamicEntity" -> "CanUpdateDynamicEntityDefinition", + "CanDeleteSystemLevelDynamicEntity" -> "CanDeleteDynamicEntityDefinition", + "CanDeleteBankLevelDynamicEntity" -> "CanDeleteDynamicEntityDefinition", + "CanGetSystemLevelDynamicEntities" -> "CanGetDynamicEntityDefinitions", + "CanGetBankLevelDynamicEntities" -> "CanGetDynamicEntityDefinitions", + "CanDeleteCascadeSystemDynamicEntity" -> "CanDeleteCascadeDynamicEntityDefinition", + "CanBackupSystemDynamicEntity" -> "CanBackupDynamicEntityDefinition", + "CanBackupBankLevelDynamicEntity" -> "CanBackupDynamicEntityDefinition" + ) + /** - * The new name for a stored Role, given the bank id the row holds, or None when the Role is not one - * of ours or has no successor. `wasSystemLevel` is true when the stored bank id is empty. + * The new name for a stored Record Role, given the bank id the row holds, or None when the Role is + * not one of ours or has no successor. `wasSystemLevel` is true when the stored bank id is empty. */ def renameOf(oldName: String, wasSystemLevel: Boolean): Option[String] = { if (rolesWithNoSuccessor.contains(oldName)) None @@ -108,25 +126,60 @@ object MigrationOfDynamicEntityRoleNames { } } + /** The new name for a stored Definition Role, or None when the Role is not an old Definition Role. */ + def definitionRenameOf(oldName: String): Option[String] = definitionRoleRenames.get(oldName) + + /** + * Is this a Dynamic Entity Role under its current name — one that names a space, so a grant of it + * at the empty bank id belongs at the system space? + */ + private def isCurrentDynamicEntityRoleName(roleName: String): Boolean = + definitionRoleRenames.values.toSet.contains(roleName) || + generatedRolePrefixRenames.map(_._2).exists(roleName.startsWith) + + /** + * One rename pass. `renameOf` gives a stored name's successor (the Boolean says the row sat at the + * empty bank id); `belongsInSpace` says whether a Role stored at the empty bank id belongs at the + * system space once renamed, and is also what decides whether a system level Group can move. + */ + private case class RenamePass( + renameOf: (String, Boolean) => Option[String], + belongsInSpace: String => Boolean + ) + /** - * Does a Role of this name move onto the system space when its row sat at the empty bank id? - * - * The Record family does: its checks resolve the space in the handler, so a system level grant now - * belongs at DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. The Definition family does not, yet: those - * endpoints are served at URLs with no space segment, so the middleware still resolves them at the - * empty bank id and moving their rows would strand them. They move in the same change that gives - * those endpoints a space in their URL. + * The Record pass moves only the Record Roles. The Definition Roles still resolved at the empty + * bank id then, so a Group holding one of them had to stay where it was. */ - private def movesToSystemSpace(oldName: String): Boolean = renameOf(oldName, wasSystemLevel = true).isDefined + private val recordPass = RenamePass( + renameOf = renameOf, + belongsInSpace = oldName => renameOf(oldName, wasSystemLevel = true).isDefined + ) - /** The space a renamed Role belongs at. */ - private def newBankId(oldName: String, oldBankId: String): String = - if (oldBankId.isEmpty && movesToSystemSpace(oldName)) DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID else oldBankId + /** + * The Definition pass moves the Definition Roles, and counts every Dynamic Entity Role already + * under its new name as belonging to a space too. That second part is what lets a system level + * Group the Record pass had to leave behind, because it also held a Definition Role, move now. + */ + private val definitionPass = RenamePass( + renameOf = (oldName, _) => definitionRenameOf(oldName), + belongsInSpace = roleName => definitionRoleRenames.contains(roleName) || isCurrentDynamicEntityRoleName(roleName) + ) + + /** Rename the Record Roles in every store. */ + def renameEverywhere(name: String): Boolean = run(name, recordPass) - def renameEverywhere(name: String): Boolean = { + /** Rename the Definition Roles in every store. */ + def renameDefinitionRolesEverywhere(name: String): Boolean = run(name, definitionPass) + + private def run(name: String, pass: RenamePass): Boolean = { val startDate = System.currentTimeMillis() val report = scala.collection.mutable.ListBuffer[String]() + /** The space a renamed Role belongs at. */ + def newBankId(oldName: String, oldBankId: String): String = + if (oldBankId.isEmpty && pass.belongsInSpace(oldName)) DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID else oldBankId + def renameRows( label: String, rows: List[(String, String, String => Unit, String => Unit)] @@ -135,7 +188,7 @@ object MigrationOfDynamicEntityRoleNames { val orphaned = scala.collection.mutable.ListBuffer[String]() rows.foreach { case (roleName, bankId, setRoleName, setBankId) => if (rolesWithNoSuccessor.contains(roleName)) orphaned += roleName - else renameOf(roleName, bankId.isEmpty).foreach { newName => + else pass.renameOf(roleName, bankId.isEmpty).foreach { newName => setRoleName(newName) setBankId(newBankId(roleName, bankId)) renamed += 1 @@ -179,22 +232,19 @@ object MigrationOfDynamicEntityRoleNames { Group.findAll().foreach { group => val wasSystemLevel = group.BankId.get.isEmpty val storedRoles = group.ListOfRoles.get.split(",").toList.map(_.trim).filter(_.nonEmpty) - val renamedRoles = storedRoles.map(r => renameOf(r, wasSystemLevel).getOrElse(r)) + val renamedRoles = storedRoles.map(r => pass.renameOf(r, wasSystemLevel).getOrElse(r)) if (renamedRoles != storedRoles) { group.ListOfRoles(renamedRoles.mkString(",")) renamedGroups += 1 } - // A system level Group grants at its own bank id, which is empty, so a Group whose Roles are - // all ours moves to the system space and keeps working. One holding a mix cannot move: its - // other Roles belong at the empty bank id and would land where nothing reads them. - // Only a Group whose Roles all move to the system space may move with them. One holding a - // Definition Role, which still resolves at the empty bank id, has to stay where it is. - val everyRoleMoves = storedRoles.nonEmpty && - storedRoles.forall(r => renameOf(r, wasSystemLevel).isDefined && movesToSystemSpace(r)) + // A system level Group grants at its own bank id, which is empty, so a Group whose Roles all + // belong in a space moves to the system space and keeps working. One holding a mix cannot + // move: its other Roles belong at the empty bank id and would land where nothing reads them. + val everyRoleMoves = storedRoles.nonEmpty && storedRoles.forall(pass.belongsInSpace) if (wasSystemLevel && everyRoleMoves) { group.BankId(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) movedGroups += 1 - } else if (wasSystemLevel && storedRoles.exists(r => renameOf(r, wasSystemLevel).isDefined && movesToSystemSpace(r))) { + } else if (wasSystemLevel && storedRoles.exists(r => pass.renameOf(r, wasSystemLevel).isDefined)) { mixedGroups += group.GroupName.get } group.save 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 5e31b9c5ac..8532d4d0e9 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 @@ -54,7 +54,7 @@ import code.consent.ConsentStatus import com.openbankproject.commons.model.enums.{AttributeCategory, AttributeType, UserInvitationPurpose} import java.util.Date import code.api.dynamic.endpoint.helper.DynamicEndpointHelper -import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.dynamic.entity.helper.{DynamicEntityInfo, DynamicEntitySpace} import code.api.util.{ApiRole => ApiRoleObj} import code.api.util.newstyle.ViewNewStyle import code.users.Users @@ -1531,7 +1531,7 @@ object Http4s400 { case req @ GET -> `prefixPath` / "management" / "system-dynamic-entities" => EndpointHelpers.withUser(req) { (user, cc) => for { - _ <- NewStyle.function.hasEntitlement("", user.userId, canGetSystemLevelDynamicEntities, Some(cc)) + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canGetDynamicEntityDefinitions, cc) dynamicEntities <- Future(NewStyle.function.getDynamicEntities(None, false)) } yield { val listCommons: List[DynamicEntityCommons] = dynamicEntities @@ -1562,9 +1562,9 @@ object Http4s400 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canGetSystemLevelDynamicEntities)), + Some(List(canGetDynamicEntityDefinitions)), http4sPartialFunction = Some(getSystemDynamicEntities) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank // ─── getBankLevelDynamicEntities ────────────────────────────────────────── @@ -1572,8 +1572,6 @@ object Http4s400 { case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" => EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => for { - _ <- NewStyle.function.hasAtLeastOneEntitlement(bank.bankId.value, user.userId, - List(canGetBankLevelDynamicEntities, canGetAnyBankLevelDynamicEntities), Some(cc)) dynamicEntities <- Future(NewStyle.function.getDynamicEntities(Some(bank.bankId.value), false)) } yield { val listCommons: List[DynamicEntityCommons] = dynamicEntities @@ -1605,7 +1603,7 @@ object Http4s400 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canGetBankLevelDynamicEntities, canGetAnyBankLevelDynamicEntities)), + Some(List(canGetDynamicEntityDefinitions)), http4sPartialFunction = Some(getBankLevelDynamicEntities) ) @@ -1694,9 +1692,8 @@ object Http4s400 { // the creator's grants go to DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than the empty bank id. // Granting at "" would write rows nothing reads, locking the creator out of the entity they // had just defined. Same rule as the v6.0.0 creation path. - val bankIdOrSYS = dynamicEntity.bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement(bankIdOrSYS, cc.userId, role.toString())) + Entitlement.entitlement.vend.addEntitlement(DynamicEntitySpace.bankIdOrSystem(dynamicEntity.bankId), cc.userId, role.toString())) val commonsData: DynamicEntityCommons = result commonsData.jValue } @@ -1722,7 +1719,7 @@ object Http4s400 { commonsData.jValue } - private def deleteDynamicEntityImpl(bankId: Option[String], dynamicEntityId: String, cc: CallContext): Future[Box[Boolean]] = + private[api] def deleteDynamicEntityImpl(bankId: Option[String], dynamicEntityId: String, cc: CallContext): Future[Box[Boolean]] = for { (entity, _) <- NewStyle.function.getDynamicEntityById(bankId, dynamicEntityId, Some(cc)) (box, _) <- NewStyle.function.invokeDynamicConnector( @@ -1745,6 +1742,7 @@ object Http4s400 { jsonObj <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).asInstanceOf[JObject] } + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canCreateDynamicEntityDefinition, cc) dynamicEntity <- tryOrApiFail(cc) { DynamicEntityCommons(jsonObj, None, cc.userId, None) } @@ -1766,8 +1764,9 @@ object Http4s400 { dynamicEntityResponseBodyExample, List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canCreateSystemLevelDynamicEntity)), + Some(List(canCreateDynamicEntityDefinition)), http4sPartialFunction = Some(createSystemDynamicEntity)) + .disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank // ─── createBankLevelDynamicEntity ───────────────────────────────────────── @@ -1802,7 +1801,7 @@ object Http4s400 { dynamicEntityResponseBodyExample, List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canCreateBankLevelDynamicEntity, canCreateAnyBankLevelDynamicEntity)), + Some(List(canCreateDynamicEntityDefinition)), http4sPartialFunction = Some(createBankLevelDynamicEntity)) // ─── updateSystemDynamicEntity ──────────────────────────────────────────── @@ -1815,6 +1814,7 @@ object Http4s400 { json <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody) } + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canUpdateDynamicEntityDefinition, cc) result <- updateDynamicEntityImpl(None, dynamicEntityId, json, cc) } yield result } @@ -1837,8 +1837,9 @@ object Http4s400 { dynamicEntityResponseBodyExample, List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityUpdateNotSchemaCompatible, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canUpdateSystemDynamicEntity)), + Some(List(canUpdateDynamicEntityDefinition)), http4sPartialFunction = Some(updateSystemDynamicEntity)) + .disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank // ─── updateBankLevelDynamicEntity ───────────────────────────────────────── @@ -1872,7 +1873,7 @@ object Http4s400 { dynamicEntityResponseBodyExample, List(BankNotFound, AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, DynamicEntityUpdateNotSchemaCompatible, UnknownError), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canUpdateBankLevelDynamicEntity)), + Some(List(canUpdateDynamicEntityDefinition)), http4sPartialFunction = Some(updateBankLevelDynamicEntity)) // ─── deleteSystemDynamicEntity (200) ───────────────────────────────────── @@ -1880,7 +1881,10 @@ object Http4s400 { lazy val deleteSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId => EndpointHelpers.withUser(req) { (_, cc) => - deleteDynamicEntityImpl(None, dynamicEntityId, cc).map(_ => JObject(Nil)) + for { + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canDeleteDynamicEntityDefinition, cc) + _ <- deleteDynamicEntityImpl(None, dynamicEntityId, cc) + } yield JObject(Nil) } } @@ -1905,9 +1909,9 @@ object Http4s400 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canDeleteSystemLevelDynamicEntity)), + Some(List(canDeleteDynamicEntityDefinition)), http4sPartialFunction = Some(deleteSystemDynamicEntity) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank // ─── deleteBankLevelDynamicEntity (200) ────────────────────────────────── @@ -1940,7 +1944,7 @@ object Http4s400 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canDeleteBankLevelDynamicEntity)), + Some(List(canDeleteDynamicEntityDefinition)), http4sPartialFunction = Some(deleteBankLevelDynamicEntity) ) 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 e485470ef0..447a40277b 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 @@ -82,7 +82,7 @@ import code.api.cache.Redis import code.bankconnectors.{Connector => BankConnector} import code.bankconnectors.storedprocedure.StoredProcedureUtils import code.migration.MigrationScriptLogProvider -import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.dynamic.entity.helper.{DynamicEntityInfo, DynamicEntitySpace} import code.api.util.APIUtil.{HTTPParam, createQueriesByHttpParamsFuture, unboxFull, unboxFullOrFail} import code.api.util.{ApiVersionUtils, CertificateUtil, CommonsEmailWrapper, RateLimitingUtil} import code.api.v2_0_0.{BasicViewJson, JSONFactory200} @@ -121,7 +121,7 @@ import code.dynamicEntity.DynamicEntityCommons import code.entitlement.Entitlement import code.metadata.tags.Tags import code.views.Views -import net.liftweb.mapper.{By, NullRef} +import net.liftweb.mapper.By import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.{BankId, BankIdAccountId, CustomerId, ListResult, ViewId} @@ -345,8 +345,9 @@ object Http4s600 { // Route: GET /obp/v6.0.0/management/system-dynamic-entities lazy val getSystemDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "system-dynamic-entities" => - EndpointHelpers.withUser(req) { (_, _) => + EndpointHelpers.withUser(req) { (_, cc) => for { + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canGetDynamicEntityDefinitions, cc) dynamicEntities <- Future(NewStyle.function.getDynamicEntities(None, false)) } yield { val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) @@ -354,7 +355,8 @@ object Http4s600 { val recordCount = DynamicData.count( By(DynamicData.DynamicEntityName, entity.entityName), By(DynamicData.IsPersonalEntity, false), - if (entity.bankId.isEmpty) NullRef(DynamicData.BankId) else By(DynamicData.BankId, entity.bankId.get) + // Records store SYS for the system space, never a NULL bank id. + By(DynamicData.BankId, DynamicEntitySpace.bankIdOrSystem(entity.bankId)) ) (entity, recordCount) } @@ -495,7 +497,7 @@ object Http4s600 { // Inlined helpers — match the v6 Lift private versions in APIMethods600. private val validEntityNamePattern = "^[a-z][a-z0-9_]*$".r.pattern - private def validateEntityNameV600(entityName: String, cc: CallContext): Future[Unit] = + private[api] def validateEntityNameV600(entityName: String, cc: CallContext): Future[Unit] = if (validEntityNamePattern.matcher(entityName).matches()) Future.successful(()) else Future.failed(new RuntimeException(s"$InvalidDynamicEntityName Current value: '$entityName'")) @@ -507,7 +509,7 @@ object Http4s600 { !NewStyle.function.getMethodRoutings(Some("dynamicEntityProcess")) .exists(_.parameters.exists(p => p.key == "entityName" && p.value == dynamicEntity.entityName)) - private def createDynamicEntityV600(cc: CallContext, dynamicEntity: DynamicEntityCommons) = for { + private[api] def createDynamicEntityV600(cc: CallContext, dynamicEntity: DynamicEntityCommons) = for { _ <- Helper.booleanToFuture(RowLevelAccessRequiresLocalBacking, 400, cc = Some(cc)) { localBackingOkForRowLevel(dynamicEntity) } // Wrap the connector call so a thrown RuntimeException (bad schema, etc.) // becomes a 400 InvalidJsonFormat — matches v6 Lift's dispatch wrapper. @@ -537,15 +539,18 @@ object Http4s600 { // the creator's grants go to DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID rather than the empty bank id. // Granting at "" would write rows nothing reads, and the creator would be locked out of the // entity they had just defined. - val bankIdOrSYS = dynamicEntity.bankId.getOrElse(code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement(bankIdOrSYS, cc.onBehalfOfUserId, role.toString(), + Entitlement.entitlement.vend.addEntitlement(DynamicEntitySpace.bankIdOrSystem(dynamicEntity.bankId), cc.onBehalfOfUserId, role.toString(), grantedByUserId = Some(cc.userId))) JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head } - private def updateDynamicEntityV600(cc: CallContext, dynamicEntity: DynamicEntityCommons) = for { + private[api] def updateDynamicEntityV600(cc: CallContext, dynamicEntity: DynamicEntityCommons) = for { _ <- Helper.booleanToFuture(RowLevelAccessRequiresLocalBacking, 400, cc = Some(cc)) { localBackingOkForRowLevel(dynamicEntity) } + // Look the definition up in its own space first, so that an id from another space, or no id at + // all, is a 404. Left to the update below, the not-found error is caught by the recoverWith and + // reported as a 400 InvalidJsonFormat. + _ <- NewStyle.function.getDynamicEntityById(dynamicEntity.bankId, dynamicEntity.dynamicEntityId.getOrElse(""), Some(cc)) Full(result) <- NewStyle.function.createOrUpdateDynamicEntity(dynamicEntity, Some(cc)) .recoverWith { case e: Throwable if !Option(e.getMessage).exists(_.startsWith("OBP-")) => @@ -569,6 +574,7 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CreateDynamicEntityRequestJsonV600] } _ <- validateEntityNameV600(request.entity_name, cc) + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canCreateDynamicEntityDefinition, cc, code.api.util.APIUtil.UserOrApplication) dynamicEntity <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { DynamicEntityCommons(JSONFactory600.convertV600RequestToInternal(request), None, cc.userId, None) } @@ -610,6 +616,7 @@ object Http4s600 { _ <- validateEntityNameV600(request.entity_name, cc) internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, None) + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canUpdateDynamicEntityDefinition, cc) result <- updateDynamicEntityV600(cc, dynamicEntity) } yield result } @@ -1937,10 +1944,8 @@ object Http4s600 { val orphaned = code.api.util.DiagnosticDynamicEntityCheck.checkOrphanedRecords(definitions) var totalDeleted: Long = 0 orphaned.foreach { orphan => - val records = if (orphan.bankId.isEmpty) - DynamicData.findAll(By(DynamicData.DynamicEntityName, orphan.entityName), NullRef(DynamicData.BankId)) - else - DynamicData.findAll(By(DynamicData.DynamicEntityName, orphan.entityName), By(DynamicData.BankId, orphan.bankId)) + // orphan.bankId is the stored form, SYS for the system space, so it matches the column as is. + val records = DynamicData.findAll(By(DynamicData.DynamicEntityName, orphan.entityName), By(DynamicData.BankId, orphan.bankId)) records.foreach { r => r.delete_!; totalDeleted += 1 } } val orphanedJson = orphaned.map(o => JSONFactory600.OrphanedDynamicEntityJsonV600(o.entityName, o.bankId, o.recordCount)) @@ -5369,7 +5374,7 @@ object Http4s600 { } } - private def backupDynamicEntityFut( + private[api] def backupDynamicEntityFut( bankIdOpt: Option[String], dynamicEntityId: String, cc: CallContext @@ -5377,7 +5382,7 @@ object Http4s600 { for { (entity, _) <- NewStyle.function.getDynamicEntityById(bankIdOpt, dynamicEntityId, Some(cc)) canGetRole = code.api.dynamic.entity.helper.DynamicEntityInfo.canGetRole(entity.entityName, entity.bankId) - _ <- NewStyle.function.hasEntitlement(entity.bankId.getOrElse(""), cc.userId, canGetRole, Some(cc)) + _ <- NewStyle.function.hasEntitlement(DynamicEntitySpace.bankIdOrSystem(entity.bankId), cc.userId, canGetRole, Some(cc)) (box, _) <- NewStyle.function.invokeDynamicConnector( com.openbankproject.commons.model.enums.DynamicEntityOperation.GET_ALL, entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) @@ -5389,7 +5394,7 @@ object Http4s600 { _ <- Future(backupDynamicEntityIo(entity, backupName, resultList)) backupCanGetRole = code.api.dynamic.entity.helper.DynamicEntityInfo.canGetRole(backupName, entity.bankId) _ <- Future(code.entitlement.Entitlement.entitlement.vend.addEntitlement( - entity.bankId.getOrElse(""), cc.userId, backupCanGetRole.toString(), + DynamicEntitySpace.bankIdOrSystem(entity.bankId), cc.userId, backupCanGetRole.toString(), grantedByUserId = Some(cc.userId))) backupEntity <- Future { code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend @@ -5406,7 +5411,8 @@ object Http4s600 { case req @ POST -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId / "backup" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext - backupDynamicEntityFut(None, dynamicEntityId, cc) + DynamicEntitySpace.requireRoleAtSystemSpace(canBackupDynamicEntityDefinition, cc) + .flatMap(_ => backupDynamicEntityFut(None, dynamicEntityId, cc)) } } @@ -5418,35 +5424,46 @@ object Http4s600 { } } + /** + * This function deletes a Dynamic Entity together with all its records, after copying both to a + * `ZZ_BAK_` entity. `bankIdOpt` is the space, None for the system space. It is shared by the v6.0.0 + * system endpoint and the v7.0.0 endpoint, which names the space in its URL. + */ + private[api] def deleteDynamicEntityCascadeFut(bankIdOpt: Option[String], dynamicEntityId: String, cc: CallContext): Future[JObject] = + for { + (entity, _) <- NewStyle.function.getDynamicEntityById(bankIdOpt, dynamicEntityId, Some(cc)) + _ <- Helper.booleanToFuture(CannotDeleteCascadePersonalEntity, cc = Some(cc)) { + !entity.hasPersonalEntity + } + (box, _) <- NewStyle.function.invokeDynamicConnector( + com.openbankproject.commons.model.enums.DynamicEntityOperation.GET_ALL, + entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) + resultList <- Future { + box.asInstanceOf[net.liftweb.common.Box[org.json4s.JsonAST.JArray]] + .openOrThrowException(s"$UnknownError ") + } + _ <- Future { + if (!entity.entityName.startsWith("ZZ_BAK_")) + backupDynamicEntityIo(entity, s"ZZ_BAK_${entity.entityName}", resultList) + } + _ <- Future.sequence { + resultList.arr.map { record => + val idField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(entity.entityName) + val recordId = (record \ idField).asInstanceOf[org.json4s.JString].s + Future(code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend.delete( + entity.bankId, entity.entityName, recordId, None, false)) + } + } + _ <- NewStyle.function.deleteDynamicEntity(bankIdOpt, dynamicEntityId) + } yield JObject(Nil) + lazy val deleteSystemDynamicEntityCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "management" / "system-dynamic-entities" / "cascade" / dynamicEntityId => EndpointHelpers.executeAndRespond(req) { implicit cc => for { - (entity, _) <- NewStyle.function.getDynamicEntityById(None, dynamicEntityId, Some(cc)) - _ <- Helper.booleanToFuture(CannotDeleteCascadePersonalEntity, cc = Some(cc)) { - !entity.hasPersonalEntity - } - (box, _) <- NewStyle.function.invokeDynamicConnector( - com.openbankproject.commons.model.enums.DynamicEntityOperation.GET_ALL, - entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) - resultList <- Future { - box.asInstanceOf[net.liftweb.common.Box[org.json4s.JsonAST.JArray]] - .openOrThrowException(s"$UnknownError ") - } - _ <- Future { - if (!entity.entityName.startsWith("ZZ_BAK_")) - backupDynamicEntityIo(entity, s"ZZ_BAK_${entity.entityName}", resultList) - } - _ <- Future.sequence { - resultList.arr.map { record => - val idField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(entity.entityName) - val recordId = (record \ idField).asInstanceOf[org.json4s.JString].s - Future(code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend.delete( - entity.bankId, entity.entityName, recordId, None, false)) - } - } - _ <- NewStyle.function.deleteDynamicEntity(None, dynamicEntityId) - } yield JObject(Nil) + _ <- DynamicEntitySpace.requireRoleAtSystemSpace(canDeleteCascadeDynamicEntityDefinition, cc) + result <- deleteDynamicEntityCascadeFut(None, dynamicEntityId, cc) + } yield result } } @@ -7004,9 +7021,9 @@ object Http4s600 { ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canGetSystemLevelDynamicEntities :: Nil), + Some(canGetDynamicEntityDefinitions :: Nil), http4sPartialFunction = Some(getSystemDynamicEntities) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(getBankLevelDynamicEntities), @@ -7043,7 +7060,7 @@ object Http4s600 { UnknownError ), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canGetBankLevelDynamicEntities :: canGetAnyBankLevelDynamicEntities :: Nil), + Some(canGetDynamicEntityDefinitions :: Nil), http4sPartialFunction = Some(getBankLevelDynamicEntities) ) resourceDocs += ResourceDoc( @@ -7310,10 +7327,10 @@ object Http4s600 { ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canCreateSystemLevelDynamicEntity :: Nil), + Some(canCreateDynamicEntityDefinition :: Nil), authMode = code.api.util.APIUtil.UserOrApplication, http4sPartialFunction = Some(createSystemDynamicEntity) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(createBankLevelDynamicEntity), @@ -7389,7 +7406,7 @@ object Http4s600 { UnknownError ), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canCreateBankLevelDynamicEntity :: Nil), + Some(canCreateDynamicEntityDefinition :: Nil), authMode = code.api.util.APIUtil.UserOrApplication, http4sPartialFunction = Some(createBankLevelDynamicEntity) ) @@ -7454,9 +7471,9 @@ object Http4s600 { ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canUpdateSystemDynamicEntity :: Nil), + Some(canUpdateDynamicEntityDefinition :: Nil), http4sPartialFunction = Some(updateSystemDynamicEntity) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(updateBankLevelDynamicEntity), @@ -7524,7 +7541,7 @@ object Http4s600 { UnknownError ), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canUpdateBankLevelDynamicEntity :: Nil), + Some(canUpdateDynamicEntityDefinition :: Nil), http4sPartialFunction = Some(updateBankLevelDynamicEntity) ) resourceDocs += ResourceDoc( @@ -13381,9 +13398,9 @@ object Http4s600 { ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canBackupSystemDynamicEntity :: Nil), + Some(canBackupDynamicEntityDefinition :: Nil), http4sPartialFunction = Some(backupSystemDynamicEntity) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(backupBankLevelDynamicEntity), @@ -13414,7 +13431,7 @@ object Http4s600 { ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canBackupBankLevelDynamicEntity :: Nil), + Some(canBackupDynamicEntityDefinition :: Nil), http4sPartialFunction = Some(backupBankLevelDynamicEntity) ) } @@ -13455,9 +13472,9 @@ object Http4s600 { UnknownError ), apiTagManageDynamicEntity :: apiTagApi :: Nil, - Some(canDeleteCascadeSystemDynamicEntity :: Nil), + Some(canDeleteCascadeDynamicEntityDefinition :: Nil), http4sPartialFunction = Some(deleteSystemDynamicEntityCascade) - ) + ).disableAutoValidateRoles() // checked in the handler at SYS: this URL names no bank resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(getCustomerInvestigationReport), diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 3b7eee529b..00327c5042 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7239,6 +7239,10 @@ object Http4s700 { http4sPartialFunction = Some(getMyCustomers) ) + // Dynamic Entity definitions, one set of URLs for every space (SYS included). Declared in their own + // object to keep this initialiser under the JVM's 64KB method limit. + resourceDocs ++= Http4s700DynamicEntityDefinitions.resourceDocs + val allRoutes: HttpRoutes[IO] = { val sorted = resourceDocs .sortBy(rd => -rd.requestUrl.split("/").count(_.nonEmpty)) @@ -7282,6 +7286,9 @@ object Http4s700 { lazy val wrappedRoutesV700Services: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => Implementations7_0_0.allRoutesWithMiddleware.run(req) + // Dynamic Entity records at /banks/BANK_ID/dynamic-entities/...: the entity set changes at + // runtime, so these have no static ResourceDoc and must be tried before the bridge claims the path. + .orElse(code.api.dynamic.entity.Http4sDynamicEntity.wrappedRoutesDynamicEntityV700.run(req)) .orElse(v700ToV600Bridge.run(req)) } } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala new file mode 100644 index 0000000000..d1e2c017bc --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala @@ -0,0 +1,353 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v7_0_0 + +import cats.effect.IO +import code.DynamicData.DynamicData +import code.api.dynamic.entity.helper.DynamicEntitySpace +import code.api.Constant.ApiPathZero +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication} +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.{CallContext, CustomJsonFormats, Glossary, NewStyle} +import code.api.v4_0_0.Http4s400.Implementations4_0_0 +import code.api.v6_0_0.Http4s600.Implementations6_0_0 +import code.api.v6_0_0._ +import code.dynamicEntity.DynamicEntityCommons +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +/** + * This object holds the v7.0.0 endpoints that manage Dynamic Entity definitions. + * + * Before v7.0.0 a definition was managed at one of two kinds of URL: `/management/system-dynamic-entities` + * for the system space and `/management/banks/BANK_ID/dynamic-entities` for a bank, each with its own + * Roles. Here there is one set of URLs, `/management/banks/BANK_ID/dynamic-entities`, and BANK_ID is + * either a bank id or SYS, the bank id of the system space. The ResourceDocs declare allowSystemSpace() + * so that the middleware lets SYS through with no bank resolved, and it checks each Definition Role at + * the BANK_ID in the URL, SYS included. Every response carries `bank_id`, SYS for the system space. + * + * The endpoints live outside Http4s700 to keep that object's initialiser clear of the JVM's 64KB method + * limit; Http4s700 adds [[resourceDocs]] to its own. The work itself is done by the v6.0.0 and v4.0.0 + * functions the older endpoints use, so the behaviour stays the same across versions. + * See DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6. + */ +object Http4s700DynamicEntityDefinitions { + + implicit val formats: Formats = CustomJsonFormats.formats + + private val implementedInApiVersion = ApiVersion.v7_0_0 + private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + val resourceDocs = ArrayBuffer[ResourceDoc]() + + /** + * The `_links` of a definition as v7.0.0 gives them: the v7.0.0 data URLs, + * `/obp/v7.0.0/banks/BANK_ID/dynamic-entities/...` with SYS for the system space, in place of the + * unversioned `/obp/dynamic-entity/[banks/BANK_ID/]...` the v6.0.0 factory builds. + */ + private def v700Links(links: Option[DynamicEntityLinksJsonV600], bankId: Option[String]): Option[DynamicEntityLinksJsonV600] = { + val unversionedPrefix = s"^/obp/${ApiVersion.`dynamic-entity`}(/banks/[^/]+)?" + val v700Prefix = s"/obp/${implementedInApiVersion}/banks/${DynamicEntitySpace.bankIdOrSystem(bankId)}/dynamic-entities" + links.map(l => l.copy(related = l.related.map(link => + link.copy(href = link.href.replaceFirst(unversionedPrefix, java.util.regex.Matcher.quoteReplacement(v700Prefix)))))) + } + + /** The definition as v7.0.0 returns it: `bank_id` always present (SYS for the system space), v7.0.0 links. */ + private def withBankId(definition: DynamicEntityDefinitionJsonV600): DynamicEntityDefinitionJsonV600 = + definition.copy( + bank_id = Some(DynamicEntitySpace.bankIdOrSystem(definition.bank_id)), + _links = v700Links(definition._links, definition.bank_id)) + + private val exampleSchema = com.openbankproject.commons.util.JsonAliases.parse( + """{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}}}""" + ).asInstanceOf[org.json4s.JsonAST.JObject] + + private val exampleDefinition = DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = Some("SYS"), + has_personal_entity = true, + schema = exampleSchema + ) + + private val spaceDescription = + s"""BANK_ID is the space the definition lives in: the id of a bank, or `SYS` for the system space. + |The Role is checked at that BANK_ID, so a Role granted at `SYS` covers the system space and nothing else. + |Every response carries `bank_id`, `SYS` included.""".stripMargin + + // Route: GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities + lazy val getDynamicEntityDefinitions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" => + EndpointHelpers.withUser(req) { (_, _) => + val bankId = DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl) + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntities(bankId, false)) + } yield { + val entitiesWithCounts = dynamicEntities.sortBy(_.entityName).map { entity => + val commons: DynamicEntityCommons = entity + val recordCount = DynamicData.count( + By(DynamicData.DynamicEntityName, entity.entityName), + By(DynamicData.IsPersonalEntity, false), + By(DynamicData.BankId, DynamicEntitySpace.bankIdOrSystem(entity.bankId)) + ) + (commons, recordCount) + } + val listed = JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts) + listed.copy(dynamic_entities = listed.dynamic_entities.map(definition => + definition.copy( + bank_id = Some(DynamicEntitySpace.bankIdOrSystem(definition.bank_id)), + _links = v700Links(definition._links, definition.bank_id)))) + } + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getDynamicEntityDefinitions), + "GET", + "/management/banks/BANK_ID/dynamic-entities", + "Get Dynamic Entity Definitions", + s"""Get the Dynamic Entity definitions in one space, each with a `record_count` of its non-personal records. + | + |$spaceDescription + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + DynamicEntitiesWithCountJsonV600(List(DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = Some("SYS"), + has_personal_entity = true, + schema = exampleSchema, + record_count = 42 + ))), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canGetDynamicEntityDefinitions :: Nil), + http4sPartialFunction = Some(getDynamicEntityDefinitions) + ).allowSystemSpace() + + // Route: POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities (201) + lazy val createDynamicEntityDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CreateDynamicEntityRequestJsonV600] + } + _ <- Implementations6_0_0.validateEntityNameV600(request.entity_name, cc) + dynamicEntity <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + DynamicEntityCommons(JSONFactory600.convertV600RequestToInternal(request), None, cc.userId, + DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl)) + } + result <- Implementations6_0_0.createDynamicEntityV600(cc, dynamicEntity) + } yield withBankId(result) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createDynamicEntityDefinition), + "POST", + "/management/banks/BANK_ID/dynamic-entities", + "Create Dynamic Entity Definition", + s"""Create a Dynamic Entity definition in one space. + | + |$spaceDescription + | + |The request body is the same as v6.0.0's: `entity_name` in lowercase snake_case, the access flags + |(`has_personal_entity`, `has_public_access`, `has_community_access`, `personal_requires_role`, + |`use_row_level_access`, `auth_mode`) and a `schema` whose every property carries an `example`. + | + |The caller is granted the new entity's Record Roles (`CanCreateDynamicEntityRecord_` and + |its Get, Update and Delete siblings) at the same BANK_ID. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")} and ${Glossary.getGlossaryItemLink("Dynamic-Entity-Access-Model")}""", + CreateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = exampleSchema + ), + exampleDefinition, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canCreateDynamicEntityDefinition :: Nil), + authMode = UserOrApplication, + http4sPartialFunction = Some(createDynamicEntityDefinition) + ).allowSystemSpace() + + // Route: PUT /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID (200) + lazy val updateDynamicEntityDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- Implementations6_0_0.validateEntityNameV600(request.entity_name, cc) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, + DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl)) + result <- Implementations6_0_0.updateDynamicEntityV600(cc, dynamicEntity) + } yield withBankId(result) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(updateDynamicEntityDefinition), + "PUT", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update Dynamic Entity Definition", + s"""Update a Dynamic Entity definition in one space. A definition in another space is not found, whatever its id. + | + |$spaceDescription + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = exampleSchema + ), + exampleDefinition, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + DynamicEntityNotFoundByDynamicEntityId, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canUpdateDynamicEntityDefinition :: Nil), + http4sPartialFunction = Some(updateDynamicEntityDefinition) + ).allowSystemSpace() + + // Route: DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID (204) + lazy val deleteDynamicEntityDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.executeDelete(req) { cc => + Implementations4_0_0.deleteDynamicEntityImpl(DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl), dynamicEntityId, cc) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteDynamicEntityDefinition), + "DELETE", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", + "Delete Dynamic Entity Definition", + s"""Delete a Dynamic Entity definition that has no records. To delete one together with its records, + |use the cascade endpoint. + | + |$spaceDescription + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + EmptyBody, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicEntityOperationNotAllowed, + DynamicEntityNotFoundByDynamicEntityId, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canDeleteDynamicEntityDefinition :: Nil), + http4sPartialFunction = Some(deleteDynamicEntityDefinition) + ).allowSystemSpace() + + // Route: POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup (201) + lazy val backupDynamicEntityDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "backup" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + Implementations6_0_0.backupDynamicEntityFut(DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl), dynamicEntityId, cc) + .map(withBankId) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(backupDynamicEntityDefinition), + "POST", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup", + "Backup Dynamic Entity Definition", + s"""Copy a Dynamic Entity definition and all its records to a new entity in the same space, named with a + |`_BAK` suffix (`_BAK2`, `_BAK3` and so on when that name is taken). + | + |The caller needs the entity's `CanGetDynamicEntityRecord_` Role as well, since the copy reads + |every record, and is granted the same Role on the backup. + | + |$spaceDescription + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + exampleDefinition.copy(entity_name = "customer_preferences_BAK", has_personal_entity = false), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicEntityNotFoundByDynamicEntityId, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canBackupDynamicEntityDefinition :: Nil), + http4sPartialFunction = Some(backupDynamicEntityDefinition) + ).allowSystemSpace() + + // Route: DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID (204) + lazy val deleteDynamicEntityDefinitionCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / "cascade" / dynamicEntityId => + EndpointHelpers.executeDelete(req) { cc => + Implementations6_0_0.deleteDynamicEntityCascadeFut(DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl), dynamicEntityId, cc) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteDynamicEntityDefinitionCascade), + "DELETE", + "/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID", + "Delete Dynamic Entity Definition Cascade", + s"""Delete a Dynamic Entity definition together with all its records. + | + |The definition and its records are first copied to an entity named with a `ZZ_BAK_` prefix in the same + |space, overwriting an earlier `ZZ_BAK_` copy; an entity whose name already starts with `ZZ_BAK_` is not + |copied again. Only an entity without personal (`my`) records can be deleted this way. + | + |$spaceDescription + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + EmptyBody, + EmptyBody, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, CannotDeleteCascadePersonalEntity, + DynamicEntityNotFoundByDynamicEntityId, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canDeleteCascadeDynamicEntityDefinition :: Nil), + http4sPartialFunction = Some(deleteDynamicEntityDefinitionCascade) + ).allowSystemSpace() +} diff --git a/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala index 2d123ea23f..6b2dda940f 100644 --- a/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala +++ b/obp-api/src/test/scala/code/api/sweep/AnyBankScopeSweepTest.scala @@ -63,7 +63,6 @@ class AnyBankScopeSweepTest extends ServerSetupWithTestData { private val rolesThatReachEveryBank: Set[String] = Set( "CanAddUserToGroupAtAllBanks", "CanCreateAccountAccessRequestAtAnyBank", - "CanCreateAnyBankLevelDynamicEntity", "CanCreateAtmAtAnyBank", "CanCreateAtmAttributeAtAnyBank", "CanCreateBranchAtAnyBank", @@ -89,7 +88,6 @@ class AnyBankScopeSweepTest extends ServerSetupWithTestData { "CanGetAccountAccessRequestsAtAnyBank", "CanGetAccountsHeldAtAnyBank", "CanGetAccountsMinimalForCustomerAtAnyBank", - "CanGetAnyBankLevelDynamicEntities", "CanGetAtmAttributeAtAnyBank", "CanGetConsentsAtAnyBank", "CanGetCorrelatedUsersInfoAtAnyBank", diff --git a/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala index 64fe327be5..84fe1a66a7 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/AuthenticationTypeValidationTest.scala @@ -428,7 +428,7 @@ class AuthenticationTypeValidationTest extends V400ServerSetup { } // prepare one dynamic entity FooBar private def addSystemDynamicEntity(): APIResponse = { - grantEntitlement(canCreateSystemLevelDynamicEntity) + grantEntitlement(canCreateDynamicEntityDefinition, code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@ user1 val fooBar = s""" 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 index d661ad8419..cf9fe34d21 100644 --- 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 @@ -25,6 +25,7 @@ TESOBE (http://www.tesobe.com/) */ package code.api.v4_0_0 +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.util.ApiRole._ import code.api.util.ErrorMessages.DynamicCodeExecutionDisabled @@ -235,7 +236,7 @@ class DynamicCodeKillSwitchTest extends V400ServerSetup with EnvVarOverride { 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) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val entityJson = org.json4s.native.JsonMethods.parse( """ @@ -263,7 +264,7 @@ class DynamicCodeKillSwitchTest extends V400ServerSetup with EnvVarOverride { val dynamicEntityId = (response.body \ "dynamic_entity_id").extract[String] - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@ (user1) makeDeleteRequest(deleteRequest) } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala index 2a7968a0f3..4200fbb565 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicEntityTest.scala @@ -263,13 +263,13 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("CRUD Dynamic - without the proper Role" , ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) { - When("We make a request v4.0.0 without a Role " + canCreateSystemLevelDynamicEntity) + When("We make a request v4.0.0 without a Role " + canCreateDynamicEntityDefinition) val request400 = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response400 = makePostRequest(request400, write(rightEntity)) Then("We should get a 403") response400.code should equal(403) - And("error should be " + UserHasMissingRoles + CanCreateSystemLevelDynamicEntity) - response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanCreateSystemLevelDynamicEntity) + And("error should be " + UserHasMissingRoles + CanCreateDynamicEntityDefinition) + response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanCreateDynamicEntityDefinition) { When(s"We make a request $ApiEndpoint2 v4.0.0") @@ -277,8 +277,8 @@ class DynamicEntityTest extends V400ServerSetup { val response400 = makePutRequest(request400, write(rightEntity)) Then("We should get a 403") response400.code should equal(403) - And("error should be " + UserHasMissingRoles + CanUpdateSystemLevelDynamicEntity) - response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanUpdateSystemLevelDynamicEntity) + And("error should be " + UserHasMissingRoles + CanUpdateDynamicEntityDefinition) + response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanUpdateDynamicEntityDefinition) } { @@ -287,8 +287,8 @@ class DynamicEntityTest extends V400ServerSetup { val response400 = makeGetRequest(request400) Then("We should get a 403") response400.code should equal(403) - And("error should be " + UserHasMissingRoles + CanGetSystemLevelDynamicEntities) - response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanGetSystemLevelDynamicEntities) + And("error should be " + UserHasMissingRoles + CanGetDynamicEntityDefinitions) + response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanGetDynamicEntityDefinitions) } { @@ -297,16 +297,16 @@ class DynamicEntityTest extends V400ServerSetup { val response400 = makeDeleteRequest(request400) Then("We should get a 403") response400.code should equal(403) - And("error should be " + UserHasMissingRoles + CanDeleteSystemLevelDynamicEntity) - response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanDeleteSystemLevelDynamicEntity) + And("error should be " + UserHasMissingRoles + CanDeleteDynamicEntityDefinition) + response400.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + CanDeleteDynamicEntityDefinition) } } scenario("Create Dynamic - two users can not create the same entity name", ApiEndpoint1, VersionOfApi) { When("We make a request v4.0.0") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) val request400User1 = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response400User1 = makePostRequest(request400User1, write(rightEntity)) Then("We should get a 201") @@ -322,7 +322,7 @@ class DynamicEntityTest extends V400ServerSetup { scenario("Create Dynamic - the request json root can only contains two objects: entity and hasPersonalEntity ", ApiEndpoint1, VersionOfApi) { When("We make a request v4.0.0") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) val request400User2 = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user2) val response400User2 = makePostRequest(request400User2, write(wrongRootEntity)) @@ -335,7 +335,7 @@ class DynamicEntityTest extends V400ServerSetup { scenario("Create Dynamic - the request json root can only contains two objects: entity and hasPersonalEntity, test2 ", ApiEndpoint1, VersionOfApi) { When("We make a request v4.0.0") - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) val request400User2 = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user2) val response400User2 = makePostRequest(request400User2, write(wrongRootEntity2)) @@ -347,7 +347,7 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("We will test the successful cases " , ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(rightEntity)) @@ -373,8 +373,8 @@ class DynamicEntityTest extends V400ServerSetup { responseJson shouldEqual expectCreateResponseJson - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + canUpdateSystemDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + canUpdateDynamicEntityDefinition) { // update success @@ -425,8 +425,8 @@ class DynamicEntityTest extends V400ServerSetup { response400.body.extract[ErrorMessage].message should startWith (DynamicEntityInstanceValidateFail) } - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLevelDynamicEntities.toString) - When("We make a request v4.0.0 with the Role " + canGetSystemLevelDynamicEntities) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) + When("We make a request v4.0.0 with the Role " + canGetDynamicEntityDefinitions) val requestGet = (v4_0_0_Request / "management" / "system-dynamic-entities").GET <@(user1) val responseGet = makeGetRequest(requestGet) Then("We should get a 200") @@ -438,15 +438,15 @@ class DynamicEntityTest extends V400ServerSetup { dynamicEntitiesGetJson.arr should contain(expectUpdatedResponseJson) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + canDeleteSystemLevelDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + canDeleteDynamicEntityDefinition) val requestDelete400 = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) val responseDelete400 = makeDeleteRequest(requestDelete400) Then("We should get a 200") responseDelete400.code should equal(200) { - When(s"We $canGetSystemLevelDynamicEntities again, it return empty") + When(s"We $canGetDynamicEntityDefinitions again, it return empty") val requestGet = (v4_0_0_Request / "management" / "system-dynamic-entities").GET <@(user1) val responseGet = makeGetRequest(requestGet) Then("We should get a 200") @@ -509,7 +509,7 @@ class DynamicEntityTest extends V400ServerSetup { Then("We should get a 403") response400.code should equal(403) response400.body.extract[ErrorMessage].message contains UserHasMissingRoles should be (true) - response400.body.extract[ErrorMessage].message contains CanCreateBankLevelDynamicEntity.toString() should be (true) + response400.body.extract[ErrorMessage].message contains CanCreateDynamicEntityDefinition.toString() should be (true) { @@ -519,7 +519,7 @@ class DynamicEntityTest extends V400ServerSetup { Then("We should get a 403") response400.code should equal(403) response400.body.extract[ErrorMessage].message contains UserHasMissingRoles should be (true) - response400.body.extract[ErrorMessage].message contains CanUpdateBankLevelDynamicEntity.toString() should be (true) + response400.body.extract[ErrorMessage].message contains CanUpdateDynamicEntityDefinition.toString() should be (true) } { @@ -529,7 +529,7 @@ class DynamicEntityTest extends V400ServerSetup { Then("We should get a 403") response400.code should equal(403) response400.body.extract[ErrorMessage].message contains UserHasMissingRoles should be (true) - response400.body.extract[ErrorMessage].message contains CanGetBankLevelDynamicEntities.toString() should be (true) + response400.body.extract[ErrorMessage].message contains CanGetDynamicEntityDefinitions.toString() should be (true) } { @@ -539,14 +539,14 @@ class DynamicEntityTest extends V400ServerSetup { Then("We should get a 403") response400.code should equal(403) response400.body.extract[ErrorMessage].message contains UserHasMissingRoles should be (true) - response400.body.extract[ErrorMessage].message contains CanDeleteBankLevelDynamicEntity.toString() should be (true) + response400.body.extract[ErrorMessage].message contains CanDeleteDynamicEntityDefinition.toString() should be (true) } } scenario("Create Dynamic - two users can not the same entity name at same bank", ApiEndpoint9, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) val request400User1BankLevel = (v4_0_0_Request / "management" / "banks"/ testBankId1.value / "dynamic-entities").POST <@(user1) val response400User1BankLevel = makePostRequest(request400User1BankLevel, write(rightEntity)) Then("We should get a 201") @@ -564,8 +564,8 @@ class DynamicEntityTest extends V400ServerSetup { When("We make a request v4.0.0") Then(s"we test the Bank Level $ApiEndpoint9") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val request400User1BankLevel = (v4_0_0_Request / "management" / "banks"/ testBankId1.value / "dynamic-entities").POST <@(user1) val response400User1BankLevel = makePostRequest(request400User1BankLevel, write(rightEntity)) Then("We should get a 201") @@ -578,7 +578,7 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("We will test the successful cases ", ApiEndpoint8, ApiEndpoint9, ApiEndpoint10, ApiEndpoint11, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "management" / "banks" /testBankId1.value/ "dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(rightEntity)) @@ -611,7 +611,7 @@ class DynamicEntityTest extends V400ServerSetup { { Then(s"We test $ApiEndpoint8") - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) val requestGet = (v4_0_0_Request / "management" / "banks" / testBankId1.value / "dynamic-entities").GET <@ (user1) val responseGet = makeGetRequest(requestGet) responseGet.code should equal(200) @@ -628,15 +628,15 @@ class DynamicEntityTest extends V400ServerSetup { val responseGet = makeGetRequest(requestGet) Then("We should get a 403") responseGet.code should equal(403) - And("error should be " + UserHasMissingRoles + CanGetBankLevelDynamicEntities) + And("error should be " + UserHasMissingRoles + CanGetDynamicEntityDefinitions) val errorMessage = responseGet.body.extract[ErrorMessage].message errorMessage contains UserHasMissingRoles should be (true) - errorMessage contains CanGetBankLevelDynamicEntities.toString() should be (true) + errorMessage contains CanGetDynamicEntityDefinitions.toString() should be (true) //we grant the role and try it again. { - Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) val responseGet = makeGetRequest(requestGet) Then("We should get a 200") responseGet.code should equal(200) @@ -649,8 +649,8 @@ class DynamicEntityTest extends V400ServerSetup { } - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanUpdateBankLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + CanUpdateSystemLevelDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + CanUpdateDynamicEntityDefinition) { // update success @@ -701,8 +701,8 @@ class DynamicEntityTest extends V400ServerSetup { response400.body.extract[ErrorMessage].message should startWith (DynamicEntityInstanceValidateFail) } - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + CanCreateBankLevelDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + CanCreateDynamicEntityDefinition) val requestGet = (v4_0_0_Request / "management" / "banks" /testBankId1.value/ "dynamic-entities").GET <@(user1) val responseGet = makeGetRequest(requestGet) Then("We should get a 200") @@ -714,8 +714,8 @@ class DynamicEntityTest extends V400ServerSetup { dynamicEntitiesGetJson.arr should contain(expectUpdatedResponseJson) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + CanDeleteSystemLevelDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + CanDeleteDynamicEntityDefinition) val requestDelete400 = (v4_0_0_Request / "management" / "banks" /testBankId1.value/ "dynamic-entities" / dynamicEntityId).DELETE <@(user1) val responseDelete400 = makeDeleteRequest(requestDelete400) Then("We should get a 200") @@ -752,8 +752,8 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("Test the CRUD Success cases ", ApiEndpoint1, ApiEndpoint5, ApiEndpoint6, ApiEndpoint7, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("we first create system level entity") val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(rightEntity)) @@ -930,8 +930,8 @@ class DynamicEntityTest extends V400ServerSetup { scenario("We will test the successful cases ", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, ApiEndpoint5, ApiEndpoint6, ApiEndpoint7, ApiEndpoint8, ApiEndpoint9, VersionOfApi) { // First, we create the system level dynamic entity - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1) val response = makePostRequest(request, write(rightEntity)) @@ -965,9 +965,9 @@ class DynamicEntityTest extends V400ServerSetup { val expectUpdatedResponseJson: JValue = expectCreateResponseJson merge newNameValue responseJson shouldEqual expectCreateResponseJson - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanUpdateBankLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + CanUpdateSystemLevelDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + CanUpdateDynamicEntityDefinition) { // can update system entity @@ -1025,8 +1025,8 @@ class DynamicEntityTest extends V400ServerSetup { } } - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLevelDynamicEntities.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) { // get system entity return one record @@ -1062,9 +1062,9 @@ class DynamicEntityTest extends V400ServerSetup { } } - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) - When("We make a request v4.0.0 with the Role " + CanDeleteSystemLevelDynamicEntity) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) + When("We make a request v4.0.0 with the Role " + CanDeleteDynamicEntityDefinition) // delete system level entity using bank level endpoint -- failed val requestDelete400 = (v4_0_0_Request / "management" / "banks" / testBankId1.value / "dynamic-entities" / dynamicEntityId).DELETE <@ (user1) @@ -1129,10 +1129,10 @@ class DynamicEntityTest extends V400ServerSetup { feature("Test CRUD Foobar Records and Roles (both Bank and System levels) ") { scenario("We create the system and bank level entities, and check the Foobar roles ", ApiEndpoint1, ApiEndpoint5, ApiEndpoint6, ApiEndpoint8, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val requestBankLevel = (v4_0_0_Request / "management" /"banks" /testBankId1.value/ "dynamic-entities").POST <@(user1) val foobarObject = parse("""{ "name":"James Brown", "number":698761728}""".stripMargin) @@ -1363,14 +1363,14 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("when user1 create fooBar, and delete the foobar entity, user2 create foobar again. user1 should not have the role for it " , ApiEndpoint1, ApiEndpoint5, ApiEndpoint6, ApiEndpoint8, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanDeleteSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanDeleteBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanDeleteDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanDeleteDynamicEntityDefinition.toString) When("We make a request v4.0.0") val requestSystemLevel = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val requestBankLevel = (v4_0_0_Request / "management" / "banks" /testBankId1.value / "dynamic-entities").POST <@(user1) @@ -1505,8 +1505,8 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("User1 create System Foobar, user2 create bank Foobar, test the roles..", VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) val foobarObject = parse("""{ "name":"James Brown", "number":698761728}""".stripMargin) val foobarUpdateObject = parse("""{ "name":"James Brown123", "number":698761728}""".stripMargin) @@ -1638,8 +1638,8 @@ class DynamicEntityTest extends V400ServerSetup { feature("Update a populated Dynamic Entity: schema-compatible changes only") { scenario("indexed:true can be switched on with data present; structural changes are still refused", ApiEndpoint1, ApiEndpoint4, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) When("we create the FooBar entity and insert one record") val response = makePostRequest((v4_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1), write(rightEntity)) @@ -1700,9 +1700,9 @@ class DynamicEntityTest extends V400ServerSetup { feature("Test personal CRUD Records.") { scenario("User1 Create System Foobar, user1 and user2 both CRUD their own myFooBars. ", ApiEndpoint1, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanGetSystemLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, CanGetDynamicEntityDefinitions.toString) Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanCreateDynamicEntityRecord_FooBar") Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") When("We make a request v4.0.0") @@ -1826,8 +1826,8 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("User1 Create Bank Foobar, user1 and user2 both CRUD their own myFooBars.", ApiEndpoint8, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanCreateDynamicEntityRecord_FooBar") Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, "CanGetDynamicEntityRecord_FooBar") When("We make a request v4.0.0") @@ -1951,7 +1951,7 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("User1 Create System Level Foobar and set hasPersonalEntity = false, then there will be no my endpoints at all" , ApiEndpoint1, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val requestSystemLevel = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1) @@ -1992,7 +1992,7 @@ class DynamicEntityTest extends V400ServerSetup { } scenario("User1 Create Bank Level Foobar and set hasPersonalEntity = false, then there will be no my endpoints at all" , ApiEndpoint1, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val requestSystemLevel = (v4_0_0_Request / "management" / "banks" / testBankId1.value / "dynamic-entities").POST <@ (user1) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicIntegrationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicIntegrationTest.scala index 50e272abd4..991a9d2f99 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicIntegrationTest.scala @@ -65,7 +65,7 @@ class DynamicIntegrationTest extends V400ServerSetup { feature(s"test Dynamic Entity/Endpoint and endpoint mappings together $ApiEndpoint1 $ApiEndpoint2 $ApiEndpoint3") { scenario("test Dynamic Entity/Endpoint and endpoint mappings together ", DynamicIntegration, VersionOfApi) { //First, we need to prepare the dynamic entity, it should have two fields: name, balance. - Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val requestEntity = (v4_0_0_Request / "management" / "banks" / testBankId1.value / "dynamic-entities").POST <@(user1) val responseEntity = makePostRequest(requestEntity, write(dynamicEntity)) Then("We should get a 201") diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala index b3996944c1..0dca573934 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ForceErrorValidationTest.scala @@ -664,7 +664,7 @@ class ForceErrorValidationTest extends V400ServerSetup with PropsReset { // prepare one dynamic entity FooBar private def addSystemDynamicEntity(): APIResponse = { - addEntitlement(canCreateSystemLevelDynamicEntity) + addEntitlement(canCreateDynamicEntityDefinition, code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@ user1 val fooBar = s""" diff --git a/obp-api/src/test/scala/code/api/v4_0_0/GetScannedApiVersionsTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/GetScannedApiVersionsTest.scala index e2a67b0133..232d2fd92b 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/GetScannedApiVersionsTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/GetScannedApiVersionsTest.scala @@ -25,6 +25,7 @@ TESOBE (http://www.tesobe.com/) */ package code.api.v4_0_0 +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID import code.api.util.ApiVersionUtils import code.api.util.APIUtil import code.api.util.ApiRole._ @@ -53,7 +54,7 @@ class GetScannedApiVersionsTest extends V400ServerSetup with PropsReset { scenario("We get all the scanned API versions with disabled versions filtered out", ApiEndpoint, VersionOfApi) { // api_disabled_versions=[OBPv3.0.0,BGv1.3] setPropsValues("api_disabled_versions"-> "[OBPv3.0.0,BGv1.3]") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "api" / "versions").GET @@ -84,7 +85,7 @@ class GetScannedApiVersionsTest extends V400ServerSetup with PropsReset { scenario("We get all the scanned API versions with disabled versions filtered out", ApiEndpoint, VersionOfApi) { // api_enabled_versions=[OBPv2.2.0,OBPv3.0.0,UKv2.0] setPropsValues("api_enabled_versions"-> "[OBPv2.2.0,OBPv3.0.0,UKv2.0,OBPv4.0.0]") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "api" / "versions").GET @@ -115,7 +116,7 @@ class GetScannedApiVersionsTest extends V400ServerSetup with PropsReset { feature("Get all scanned API versions should works") { scenario("We get all the scanned API versions", ApiEndpoint, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We make a request v4.0.0") val request = (v4_0_0_Request / "api" / "versions").GET diff --git a/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala index c9c343ece7..b9c55bea16 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/JsonSchemaValidationTest.scala @@ -433,7 +433,7 @@ class JsonSchemaValidationTest extends V400ServerSetup { } // prepare one dynamic entity FooBar private def addSystemDynamicEntity(): APIResponse = { - addEntitlement(canCreateSystemLevelDynamicEntity) + addEntitlement(canCreateDynamicEntityDefinition, code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) val request = (v4_0_0_Request / "management" / "system-dynamic-entities").POST <@ user1 val fooBar = s""" diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala index 1ac3f6e2f5..511aaaeede 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAccessFlagsTest.scala @@ -51,14 +51,14 @@ class DynamicEntityAccessFlagsTest extends V600ServerSetup { // ==================== Helper Methods ==================== def createSystemEntity(entityJson: JValue): (Int, JValue) = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(entityJson)) (response.code, response.body) } def deleteSystemEntity(dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala index 5bdb9765bd..adc0fcee1b 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityAuthModeTest.scala @@ -70,13 +70,13 @@ class DynamicEntityAuthModeTest extends V600ServerSetup { } def createSystemEntity(json: JValue): (Int, JValue) = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val response = makePostRequest((v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1), write(json)) (response.code, response.body) } def deleteSystemEntity(dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) makeDeleteRequest((v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1)) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala index f9b79a6e4a..3331d28bc7 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityConsentUserTest.scala @@ -70,14 +70,14 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { private val consumerKeyHeader = List((RequestHeader.`Consumer-Key`, user1.map(_._1.key).getOrElse("SHOULD_NOT_HAPPEN"))) private def createSystemEntity(name: String, personalRequiresRole: Boolean = false): String = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val response = makePostRequest((v6_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1), write(definition(name, personalRequiresRole))) response.code should equal(201) (response.body \ "dynamic_entity_id").extract[String] } private def deleteSystemEntity(dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) makeDeleteRequest((v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@ (user1)) } @@ -92,27 +92,17 @@ class DynamicEntityConsentUserTest extends V600ServerSetup { // A Dynamic Entity Record Role names its space, and these entities are system level, so the // consent has to carry SYS as the bank id; an entitlement offered at the empty bank id would be // written where no check reads it. ConsentUtil honours the Role's own requiresBankId. - ("entitlements" -> roleNames.map(role => ("bank_id" -> emptyBankIdOrSYS(role)) ~ ("role_name" -> role))) ~ + ("entitlements" -> roleNames.map(role => ("bank_id" -> DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) ~ ("role_name" -> role))) ~ ("consumer_id" -> testConsumer.consumerId.get) ~ ("time_to_live" -> 3600) myResources.map(mr => base ~ ("my_resources" -> mr)).getOrElse(base) } /** POST a consent as user1 carrying `roleNames` and the given my_resources block; returns the raw response. */ - /** - * The bank id a Role is granted at: SYS for the Dynamic Entity Record Roles, which name their - * space, and the empty one for the Definition Roles, which do not yet. See - * DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6, after which everything here is SYS. - */ - private def emptyBankIdOrSYS(role: String): String = - if (role.contains("DynamicEntityRecord_") || role.contains("DynamicEntityField_") || - role.contains("DynamicEntityRowAccess_")) DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID - else "" - private def postConsent(roleNames: List[String], myResources: Option[JValue]) = { setPropsValues("consents.allowed" -> "true", "consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE") // The granting human must hold the Role before a consent may carry it, and at the same space. - roleNames.foreach(role => Entitlement.entitlement.vend.addEntitlement(emptyBankIdOrSYS(role), resourceUser1.userId, role)) + roleNames.foreach(role => Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, role)) makePostRequest((v6_0_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1), write(consentBody(roleNames, myResources)), consumerKeyHeader) } 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 306f28efef..af0b38314e 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 @@ -50,55 +50,27 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { // ==================== Helpers ==================== - // Every Role this suite grants belongs to a system level entity, and a Dynamic Entity Record or - // field Role names its space: the system space is DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, not the - // empty bank id. The Definition Roles still resolve at the empty bank id until their endpoints - // carry a space in the URL, so they are granted separately below. + // Every Role this suite grants belongs to a system level entity, and every Dynamic Entity Role -- + // Definition, Record and field alike -- names its space: the system space is + // DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, not the empty bank id. private def grant(role: String): Unit = - Entitlement.entitlement.vend.addEntitlement(emptyBankIdOrSYS(role), resourceUser1.userId, role) - - /** - * The bank id to grant a Role at: the empty one, or SYS. - * - * Everything this suite grants for a system level entity -- the Record Roles, the auto-generated - * field Roles, and an explicit shared field Role the schema names itself -- is bank scoped, and its - * space is the system space, DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID. The exceptions are the Definition - * Roles, which still resolve at the empty bank id because their endpoints carry no space in the - * URL; they are listed rather than pattern matched, because an explicit field Role can be named - * anything at all and would otherwise have to be guessed at. - * - * The whole helper goes away at phase 6, when the Definition endpoints gain a space and both - * families are granted the same way. - */ - private val definitionRolesStillAtTheEmptyBankId: Set[String] = Set( - CanCreateSystemLevelDynamicEntity.toString, - CanUpdateSystemLevelDynamicEntity.toString, - CanDeleteSystemLevelDynamicEntity.toString, - CanGetSystemLevelDynamicEntities.toString, - CanCreateBankLevelDynamicEntity.toString, - CanUpdateBankLevelDynamicEntity.toString, - CanDeleteBankLevelDynamicEntity.toString, - CanGetBankLevelDynamicEntities.toString - ) - - private def emptyBankIdOrSYS(role: String): String = - if (definitionRolesStillAtTheEmptyBankId.contains(role)) "" else DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, role) // Grant to a specific user. Creating a dynamic entity auto-grants its CRUD roles to the *creator* // (resourceUser1, via createSystemEntity), so the "no entity update role" scenarios use resourceUser2 — // a user who did not create the entity and therefore only holds what we explicitly grant here. private def grantTo(userId: String, role: String): Unit = - Entitlement.entitlement.vend.addEntitlement(emptyBankIdOrSYS(role), userId, role) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, userId, role) private def createSystemEntity(entityJson: JValue): (Int, JValue) = { - grant(CanCreateSystemLevelDynamicEntity.toString) + grant(CanCreateDynamicEntityDefinition.toString) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(entityJson)) (response.code, response.body) } private def deleteSystemEntity(dynamicEntityId: String): Unit = { - grant(CanDeleteSystemLevelDynamicEntity.toString) + grant(CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala index ec4e81f6cb..e6ee781727 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFilterAndBankAccessTest.scala @@ -74,27 +74,27 @@ class DynamicEntityFilterAndBankAccessTest extends V600ServerSetup { (("entity_name" -> entityName) ~ ("has_personal_entity" -> true) ~ ("schema" -> twoFieldSchema)) merge extraFlags def createSystemEntity(entityJson: JValue): (Int, JValue) = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(entityJson)) (response.code, response.body) } def deleteSystemEntity(dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } def createBankEntity(bankId: String, entityJson: JValue): (Int, JValue) = { - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val request = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(entityJson)) (response.code, response.body) } def deleteBankEntity(bankId: String, dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRecordIdLengthTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRecordIdLengthTest.scala index b5eac848ab..1d8e1195d4 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRecordIdLengthTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRecordIdLengthTest.scala @@ -26,6 +26,7 @@ TESOBE (http://www.tesobe.com/) */ package code.api.v6_0_0 +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ import code.api.util.ErrorMessages._ @@ -68,7 +69,7 @@ class DynamicEntityRecordIdLengthTest extends V600ServerSetup { ("name" -> ("type" -> "string") ~ ("example" -> "Alice")))) private def createEntityDefinition(): String = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1) val response = makePostRequest(request, write(entityDefinition)) response.code should equal(201) @@ -76,7 +77,7 @@ class DynamicEntityRecordIdLengthTest extends V600ServerSetup { } private def deleteEntityDefinition(dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) makeDeleteRequest((v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@ (user1)) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala index f445db4a27..eea5b03d67 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRoleRenameMigrationTest.scala @@ -43,11 +43,15 @@ import org.scalatest.Tag * is why this is migrated rather than left to every operator to re-grant, and these scenarios are what * say the mapping is the one intended. * - * Four things are checked, because each is a different decision rather than a different example: - * a Record Role is renamed and moved to the system space; a Definition Role is left alone, because its - * endpoints still resolve at the empty bank id until their URLs carry a space; a Role that authorised + * The migration runs in two passes, Record Roles first and Definition Roles later, and each is checked + * for the decisions it makes rather than for examples of them. The Record pass: a Record Role is renamed + * and moved to the system space; a Definition Role is left for the second pass; a Role that authorised * every bank has no successor and must be left for a human; and a Group holding only Record Roles moves * to the system space with them, while one holding a mix stays put so that its other Roles keep working. + * The Definition pass: the System and BankLevel variants both become the one merged Role, at SYS and at + * their bank respectively; the any-bank Roles are still left alone; and a Group the Record pass had to + * leave behind, because it also held a Definition Role, can now move, while one holding a Role from + * outside Dynamic Entities still cannot. */ class DynamicEntityRoleRenameMigrationTest extends ServerSetup { @@ -97,7 +101,7 @@ class DynamicEntityRoleRenameMigrationTest extends ServerSetup { } scenario("it leaves a Definition Role and an any-bank Role alone", RoleRenameMigration) { - Given("a Definition Role, which still resolves at the empty bank id") + Given("a Definition Role, which the Record pass leaves for the Definition pass") giveEntitlement("", "CanCreateSystemLevelDynamicEntity") And("an any-bank Role, which has no single successor") giveEntitlement("", "CanCreateAnyBankLevelDynamicEntity") @@ -138,5 +142,55 @@ class DynamicEntityRoleRenameMigrationTest extends ServerSetup { mixed.ListOfRoles.get should equal(s"CanCreateDynamicEntityRecord_$entity,CanCreateSystemLevelDynamicEntity") mixed.BankId.get should equal("") } + + scenario("the Definition pass merges the System and BankLevel variants, each in its own space", RoleRenameMigration) { + Given("a system level Definition Role at the empty bank id, and a bank level one at its bank") + giveEntitlement("", "CanUpdateSystemLevelDynamicEntity") + giveEntitlement("bank_one", "CanUpdateBankLevelDynamicEntity") + And("an any-bank Role, which has no single successor") + giveEntitlement("", "CanGetAnyBankLevelDynamicEntities") + + When("the Definition pass runs") + MigrationOfDynamicEntityRoleNames.renameDefinitionRolesEverywhere(s"definitionRoleRenameTest_$suffix") should equal(true) + + Then("both carry the merged name, the system one at the system space and the bank one at its bank") + entitlementRowsFor("CanUpdateDynamicEntityDefinition").map(_.mBankId.get).sorted should equal( + List(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, "bank_one").sorted) + + And("nothing is left under the old names") + entitlementRowsFor("CanUpdateSystemLevelDynamicEntity") shouldBe empty + entitlementRowsFor("CanUpdateBankLevelDynamicEntity") shouldBe empty + + And("the any-bank Role is untouched") + val anyBankRows = entitlementRowsFor("CanGetAnyBankLevelDynamicEntities") + anyBankRows.size should equal(1) + anyBankRows.head.mBankId.get should equal("") + } + + scenario("the Definition pass moves a Group the Record pass left behind, but not one holding another kind of Role", RoleRenameMigration) { + val entity = s"field_$suffix" + val leftBehindName = s"left_behind_group_$suffix" + val otherRoleName = s"other_role_group_$suffix" + + Given("a system level Group holding a Record Role and a Definition Role, which the Record pass left at the empty bank id") + makeGroup(leftBehindName, "", List(s"CanCreateDynamicEntity_System$entity", "CanDeleteSystemLevelDynamicEntity")) + MigrationOfDynamicEntityRoleNames.renameEverywhere(s"roleRenameTest4_$suffix") + reloadGroup(leftBehindName).BankId.get should equal("") + And("another holding a Definition Role and a Role from outside Dynamic Entities") + makeGroup(otherRoleName, "", List("CanDeleteSystemLevelDynamicEntity", "CanGetAnyUser")) + + When("the Definition pass runs") + MigrationOfDynamicEntityRoleNames.renameDefinitionRolesEverywhere(s"definitionRoleRenameTest2_$suffix") + + Then("the left-behind Group carries the new names and has moved to the system space") + val leftBehind = reloadGroup(leftBehindName) + leftBehind.ListOfRoles.get should equal(s"CanCreateDynamicEntityRecord_$entity,CanDeleteDynamicEntityDefinition") + leftBehind.BankId.get should equal(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + + And("the other Group carries the new name but stays at the empty bank id, where CanGetAnyUser belongs") + val other = reloadGroup(otherRoleName) + other.ListOfRoles.get should equal("CanDeleteDynamicEntityDefinition,CanGetAnyUser") + other.BankId.get should equal("") + } } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala index 13d17976b1..aff6730ebf 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala @@ -49,14 +49,14 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { // ==================== Helpers ==================== def createSystemEntity(entityJson: JValue): (Int, JValue) = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@ (user1) val response = makePostRequest(request, write(entityJson)) (response.code, response.body) } def deleteSystemEntity(dynamicEntityId: String): Unit = { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) makeDeleteRequest((v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@ (user1)) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala index 782ca47048..fcfe150015 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -25,6 +25,7 @@ TESOBE (http://www.tesobe.com/) */ package code.api.v6_0_0 +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole import code.api.util.ApiRole._ @@ -183,7 +184,7 @@ class DynamicEntityTest extends V600ServerSetup { } scenario("Create System Dynamic Entity - without proper role", ApiEndpoint1, VersionOfApi) { - When(s"We make a POST request without the role " + CanCreateSystemLevelDynamicEntity) + When(s"We make a POST request without the role " + CanCreateDynamicEntityDefinition) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(rightEntityV600)) Then("We should get a 403") @@ -194,7 +195,7 @@ class DynamicEntityTest extends V600ServerSetup { scenario("Create System Dynamic Entity with consumer scope (no user entitlement)", ApiEndpoint1, VersionOfApi) { // Add scope to consumer instead of entitlement to user — UserOrApplication should accept this - val addedScope = Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanCreateSystemLevelDynamicEntity.toString) + val addedScope = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer.id.get.toString, ApiRole.CanCreateDynamicEntityDefinition.toString) When("We create a dynamic entity using consumer with scope") val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -213,13 +214,13 @@ class DynamicEntityTest extends V600ServerSetup { val dynamicEntityId = (response.body \ "dynamic_entity_id").extract[String] // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } scenario("Create and verify v6.0.0 snake_case response format", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We create a dynamic entity with v6.0.0 format") val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -256,7 +257,7 @@ class DynamicEntityTest extends V600ServerSetup { val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] // Now test GET to verify the response format is consistent - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) When("We GET system dynamic entities") val getRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").GET <@(user1) @@ -281,14 +282,14 @@ class DynamicEntityTest extends V600ServerSetup { (entity \ "record_count") shouldBe a[JInt] // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } scenario("Update System Dynamic Entity with v6.0.0 format", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) // Create first val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -315,13 +316,13 @@ class DynamicEntityTest extends V600ServerSetup { (schemaField \ "description").extract[String] should equal("Updated description of this entity.") // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } scenario("Create Dynamic Entity with invalid schema should fail", ApiEndpoint1, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We try to create a dynamic entity with wrong required field") val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -339,7 +340,7 @@ class DynamicEntityTest extends V600ServerSetup { feature("v6.0.0 Bank Level Dynamic Entity endpoints with snake_case JSON") { scenario("Create Bank Level Dynamic Entity - without proper role", ApiEndpoint4, VersionOfApi) { - When(s"We make a POST request without the role " + CanCreateBankLevelDynamicEntity) + When(s"We make a POST request without the role " + CanCreateDynamicEntityDefinition) val request = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) val response = makePostRequest(request, write(rightEntityV600)) Then("We should get a 403") @@ -347,7 +348,7 @@ class DynamicEntityTest extends V600ServerSetup { } scenario("Create and GET Bank Level Dynamic Entity with v6.0.0 format", ApiEndpoint4, ApiEndpoint6, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) When("We create a bank level dynamic entity with v6.0.0 format") val request = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) @@ -367,7 +368,7 @@ class DynamicEntityTest extends V600ServerSetup { val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] // Test GET bank level - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetDynamicEntityDefinitions.toString) When("We GET bank level dynamic entities") val getRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").GET <@(user1) @@ -385,14 +386,14 @@ class DynamicEntityTest extends V600ServerSetup { (entity \ "record_count") shouldBe a[JInt] // Cleanup - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } scenario("Update Bank Level Dynamic Entity with v6.0.0 format", ApiEndpoint4, ApiEndpoint5, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateBankLevelDynamicEntity.toString) - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanUpdateBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanUpdateDynamicEntityDefinition.toString) // Create first val createRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) @@ -413,7 +414,7 @@ class DynamicEntityTest extends V600ServerSetup { (updateResponse.body \ "bank_id").extract[String] should equal(bankId) // Cleanup - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } @@ -432,7 +433,7 @@ class DynamicEntityTest extends V600ServerSetup { scenario("GET and Update My Dynamic Entities with v6.0.0 format", ApiEndpoint7, ApiEndpoint8, VersionOfApi) { // First create a system entity with hasPersonalEntity = true - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val createResponse = makePostRequest(createRequest, write(rightEntityV600)) @@ -478,7 +479,7 @@ class DynamicEntityTest extends V600ServerSetup { (updateResponse.body \ "schema" \ "description").extract[String] should equal("Updated description of this entity.") // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } @@ -496,7 +497,7 @@ class DynamicEntityTest extends V600ServerSetup { } scenario("GET Available Personal Dynamic Entities returns only entities with hasPersonalEntity=true", ApiEndpoint9, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) // Create entity WITH hasPersonalEntity = true val createRequest1 = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -537,7 +538,7 @@ class DynamicEntityTest extends V600ServerSetup { } // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest1 = (v4_0_0_Request / "management" / "system-dynamic-entities" / entityId1).DELETE <@(user1) makeDeleteRequest(deleteRequest1) val deleteRequest2 = (v4_0_0_Request / "management" / "system-dynamic-entities" / entityId2).DELETE <@(user1) @@ -549,7 +550,7 @@ class DynamicEntityTest extends V600ServerSetup { feature("v6.0.0 Dynamic Entity schema field validation") { scenario("Verify schema contains only schema structure, not entity name wrapper", ApiEndpoint1, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) val createResponse = makePostRequest(createRequest, write(rightEntityV600)) @@ -571,7 +572,7 @@ class DynamicEntityTest extends V600ServerSetup { (schemaField \ "has_personal_entity") should equal(JNothing) // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } @@ -581,7 +582,7 @@ class DynamicEntityTest extends V600ServerSetup { feature("v6.0.0 Dynamic Entity _links match resource doc URLs") { scenario("_links URLs for personal/public/community must match resource doc URLs", ApiEndpoint1, ApiEndpoint9, VersionOfApi) { - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanCreateDynamicEntityDefinition.toString) // Create entity with all access flags enabled val allFlagsEntity = parse( @@ -679,7 +680,7 @@ class DynamicEntityTest extends V600ServerSetup { linkMap should contain(("community-read", communityGetOne.get._1, communityGetOne.get._2)) // Cleanup - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteDynamicEntityDefinition.toString) val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) makeDeleteRequest(deleteRequest) } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala new file mode 100644 index 0000000000..2e9fea1ea4 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala @@ -0,0 +1,355 @@ +/** +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. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v7_0_0 + +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.{ApiRole, DiagnosticDynamicEntityCheck} +import code.api.util.ErrorMessages._ +import code.dynamicEntity.DynamicEntityProvider +import code.entitlement.Entitlement +import code.setup.ServerSetupWithTestData +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.json4s.JsonDSL._ +import org.json4s._ +import org.json4s.native.JsonMethods.parse +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +import java.util.UUID + +/** + * The v7.0.0 endpoints that manage Dynamic Entity definitions, at `/management/banks/BANK_ID/dynamic-entities`. + * + * The point of these endpoints is that one set of URLs serves every space, with the system space named + * by its bank id, SYS. So the scenarios check the three things that makes true: SYS gets through to + * the handler here while it still gets 404 wherever a real bank is needed; a Definition Role is checked + * at the BANK_ID in the URL, so a grant at one space never covers another; and every response names + * its space, SYS included. They also hold down three defects the older system endpoints had once + * records moved to SYS: record counts of zero, a backup refused for want of a Role nobody could hold, + * and every system record reported as orphaned. See DYNAMIC_ENTITY_SPACE_MODEL_PLAN.md, phase 6. + */ +class DynamicEntityDefinitionTest extends ServerSetupWithTestData { + + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Http4s700DynamicEntityDefinitions.getDynamicEntityDefinitions)) + object ApiEndpoint2 extends Tag(nameOf(Http4s700DynamicEntityDefinitions.createDynamicEntityDefinition)) + object ApiEndpoint3 extends Tag(nameOf(Http4s700DynamicEntityDefinitions.updateDynamicEntityDefinition)) + object ApiEndpoint4 extends Tag(nameOf(Http4s700DynamicEntityDefinitions.deleteDynamicEntityDefinition)) + object ApiEndpoint5 extends Tag(nameOf(Http4s700DynamicEntityDefinitions.backupDynamicEntityDefinition)) + object ApiEndpoint6 extends Tag(nameOf(Http4s700DynamicEntityDefinitions.deleteDynamicEntityDefinitionCascade)) + + private val SYS = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID + + def v7 = baseRequest / "obp" / "v7.0.0" + def dynamicEntityData = baseRequest / "obp" / "dynamic-entity" + + private def definitionsAt(bankId: String) = v7 / "management" / "banks" / bankId / "dynamic-entities" + + private def newEntityName(): String = "test_definition_" + UUID.randomUUID().toString.take(8).replace("-", "") + + private def definition(entityName: String): JValue = + ("entity_name" -> entityName) ~ + ("has_personal_entity" -> false) ~ + ("schema" -> parse( + """{"description": "Entity for the v7.0.0 definition tests.", "required": ["name"], + | "properties": {"name": {"type": "string", "maxLength": 40, "minLength": 1, "example": "Test"}}}""".stripMargin)) + + private def grant(bankId: String, role: ApiRole): Unit = + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, role.toString) + + private def errorOf(response: code.setup.APIResponse): String = response.body.extract[ErrorMessage].message + + /** Create a definition as user1 at `bankId`, granting the Role there first; returns its id. */ + private def createdAt(bankId: String, entityName: String): String = { + grant(bankId, canCreateDynamicEntityDefinition) + val response = makePostRequest(definitionsAt(bankId).POST <@ (user1), write(definition(entityName))) + response.code should equal(201) + (response.body \ "dynamic_entity_id").extract[String] + } + + private def cascadeDelete(bankId: String, dynamicEntityId: String): Unit = { + grant(bankId, canDeleteCascadeDynamicEntityDefinition) + makeDeleteRequest((definitionsAt(bankId) / "cascade" / dynamicEntityId).DELETE <@ (user1)) + } + + feature("Dynamic Entity definitions in the system space, at BANK_ID = SYS") { + + scenario("SYS reaches the handler, and the Role is required", ApiEndpoint2, VersionOfApi) { + When("no user is given") + val anonymous = makePostRequest(definitionsAt(SYS).POST, write(definition(newEntityName()))) + Then("the call is unauthorised, not a 404 for an unknown bank") + anonymous.code should equal(401) + + When("a user without the Role calls") + val refused = makePostRequest(definitionsAt(SYS).POST <@ (user1), write(definition(newEntityName()))) + Then("the call is refused for the missing Role") + refused.code should equal(403) + errorOf(refused) should include(UserHasMissingRoles) + errorOf(refused) should include(CanCreateDynamicEntityDefinition.toString) + } + + scenario("a Role granted at a bank does not cover the system space", ApiEndpoint2, VersionOfApi) { + Given("user2 holds the Role at a bank, and nowhere else") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, CanCreateDynamicEntityDefinition.toString) + When("user2 creates a definition at SYS") + val response = makePostRequest(definitionsAt(SYS).POST <@ (user2), write(definition(newEntityName()))) + Then("the call is refused: the Role is checked at SYS, where user2 holds nothing") + response.code should equal(403) + errorOf(response) should include(CanCreateDynamicEntityDefinition.toString) + } + + scenario("create, list, update, back up and delete a system level definition", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, + ApiEndpoint4, ApiEndpoint5, ApiEndpoint6, VersionOfApi) { + val entityName = newEntityName() + + When("a definition is created at SYS") + grant(SYS, canCreateDynamicEntityDefinition) + val created = makePostRequest(definitionsAt(SYS).POST <@ (user1), write(definition(entityName))) + Then("it is created, and the response names the system space") + created.code should equal(201) + (created.body \ "bank_id").extract[String] should equal(SYS) + val dynamicEntityId = (created.body \ "dynamic_entity_id").extract[String] + + try { + And("the creator can write a record, because the Record Roles were granted at SYS") + val record = makePostRequest((dynamicEntityData / entityName).POST <@ (user1), write(("name" -> "one record"): JValue)) + record.code should equal(201) + + When("the definitions at SYS are listed") + grant(SYS, canGetDynamicEntityDefinitions) + val listed = makeGetRequest(definitionsAt(SYS).GET <@ (user1)) + listed.code should equal(200) + val entry = (listed.body \ "dynamic_entities").extract[List[JObject]] + .find(e => (e \ "entity_name").extract[String] == entityName) + .getOrElse(fail(s"$entityName should be listed at SYS")) + Then("the entry names the system space and counts the record stored at SYS") + (entry \ "bank_id").extract[String] should equal(SYS) + (entry \ "record_count").extract[Long] should equal(1L) + + And("the record is not reported as orphaned by the diagnostics") + val definitions = DynamicEntityProvider.connectorMethodProvider.vend.getDynamicEntities(None, true) + DiagnosticDynamicEntityCheck.checkOrphanedRecords(definitions).map(_.entityName) should not contain entityName + + When("the definition is updated") + grant(SYS, canUpdateDynamicEntityDefinition) + val updated = makePutRequest((definitionsAt(SYS) / dynamicEntityId).PUT <@ (user1), write(definition(entityName))) + updated.code should equal(200) + (updated.body \ "bank_id").extract[String] should equal(SYS) + + When("it is backed up") + grant(SYS, canBackupDynamicEntityDefinition) + val backup = makePostRequest((definitionsAt(SYS) / dynamicEntityId / "backup").POST <@ (user1), "") + Then("the backup is made in the system space: the Record Role it needs is read at SYS") + backup.code should equal(201) + (backup.body \ "bank_id").extract[String] should equal(SYS) + (backup.body \ "entity_name").extract[String] should equal(s"${entityName}_BAK") + val backupId = (backup.body \ "dynamic_entity_id").extract[String] + cascadeDelete(SYS, backupId) + + When("the definition is deleted while it still has a record") + grant(SYS, canDeleteDynamicEntityDefinition) + val refused = makeDeleteRequest((definitionsAt(SYS) / dynamicEntityId).DELETE <@ (user1)) + Then("the plain delete is refused") + refused.code should equal(400) + errorOf(refused) should include(DynamicEntityOperationNotAllowed) + } finally { + cascadeDelete(SYS, dynamicEntityId) + } + + Then("the cascade delete removed it") + val afterwards = makeGetRequest(definitionsAt(SYS).GET <@ (user1)) + (afterwards.body \ "dynamic_entities").extract[List[JObject]] + .map(e => (e \ "entity_name").extract[String]) should not contain entityName + } + + scenario("a definition in another space is not found by id", ApiEndpoint3, VersionOfApi) { + val entityName = newEntityName() + val dynamicEntityId = createdAt(testBankId1.value, entityName) + try { + grant(SYS, canUpdateDynamicEntityDefinition) + val response = makePutRequest((definitionsAt(SYS) / dynamicEntityId).PUT <@ (user1), write(definition(entityName))) + response.code should equal(404) + errorOf(response) should include(DynamicEntityNotFoundByDynamicEntityId) + } finally cascadeDelete(testBankId1.value, dynamicEntityId) + } + } + + feature("Dynamic Entity definitions at a bank") { + + scenario("a bank level definition names its bank, and an unknown bank is still a 404", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + val entityName = newEntityName() + grant(testBankId1.value, canCreateDynamicEntityDefinition) + val created = makePostRequest(definitionsAt(testBankId1.value).POST <@ (user1), write(definition(entityName))) + created.code should equal(201) + (created.body \ "bank_id").extract[String] should equal(testBankId1.value) + cascadeDelete(testBankId1.value, (created.body \ "dynamic_entity_id").extract[String]) + + val unknown = makePostRequest(definitionsAt("no-such-bank-" + UUID.randomUUID().toString.take(8)).POST <@ (user1), + write(definition(newEntityName()))) + unknown.code should equal(404) + errorOf(unknown) should include(BankNotFound) + } + } + + private def dataAt(bankId: String) = v7 / "banks" / bankId / "dynamic-entities" + + private def flaggedDefinition(entityName: String, flags: (String, Boolean)*): JValue = + flags.foldLeft(definition(entityName).asInstanceOf[JObject]) { case (json, (flag, value)) => + JObject(json.obj.filterNot(_._1 == flag) :+ JField(flag, JBool(value))) + } + + /** Create a definition with the given flags as user1 at `bankId`; returns its id. */ + private def createdWithFlags(bankId: String, entityName: String, flags: (String, Boolean)*): String = { + grant(bankId, canCreateDynamicEntityDefinition) + val response = makePostRequest(definitionsAt(bankId).POST <@ (user1), write(flaggedDefinition(entityName, flags: _*))) + response.code should equal(201) + (response.body \ "dynamic_entity_id").extract[String] + } + + private def idOf(response: code.setup.APIResponse, entityName: String): String = + (response.body \ entityName \ s"${entityName}_id").extract[String] + + feature("Dynamic Entity records at /obp/v7.0.0/banks/BANK_ID/dynamic-entities/...") { + + scenario("every URL form works in the system space, and every response names SYS", VersionOfApi) { + val entityName = newEntityName() + val dynamicEntityId = createdWithFlags(SYS, entityName, + "has_personal_entity" -> true, "has_public_access" -> true, "has_community_access" -> true) + try { + When("a record is created, read, replaced, patched and deleted at SYS") + val created = makePostRequest((dataAt(SYS) / entityName).POST <@ (user1), write(("name" -> "first"): JValue)) + created.code should equal(201) + (created.body \ "bank_id").extract[String] should equal(SYS) + val recordId = idOf(created, entityName) + + val listed = makeGetRequest((dataAt(SYS) / entityName).GET <@ (user1)) + listed.code should equal(200) + (listed.body \ "bank_id").extract[String] should equal(SYS) + (listed.body \ s"${entityName}_list").extract[List[JObject]].map(o => (o \ s"${entityName}_id").extract[String]) should contain(recordId) + + val one = makeGetRequest((dataAt(SYS) / entityName / recordId).GET <@ (user1)) + one.code should equal(200) + (one.body \ "bank_id").extract[String] should equal(SYS) + + val replaced = makePutRequest((dataAt(SYS) / entityName / recordId).PUT <@ (user1), write(("name" -> "second"): JValue)) + replaced.code should equal(200) + (replaced.body \ entityName \ "name").extract[String] should equal("second") + + val patched = makePatchRequest((dataAt(SYS) / entityName / recordId).PATCH <@ (user1), write(("name" -> "third"): JValue)) + patched.code should equal(200) + (patched.body \ entityName \ "name").extract[String] should equal("third") + + Then("the same record is served at the unversioned URL, which still omits bank_id for the system space") + val unversioned = makeGetRequest((dynamicEntityData / entityName / recordId).GET <@ (user1)) + unversioned.code should equal(200) + (unversioned.body \ "bank_id") should equal(JNothing) + (unversioned.body \ entityName \ "name").extract[String] should equal("third") + + When("the public, community and personal forms are used") + val public = makeGetRequest((dataAt(SYS) / "public" / entityName).GET) + public.code should equal(200) + (public.body \ "bank_id").extract[String] should equal(SYS) + + val community = makeGetRequest((dataAt(SYS) / "community" / entityName).GET <@ (user1)) + community.code should equal(200) + (community.body \ "bank_id").extract[String] should equal(SYS) + + val personal = makePostRequest((dataAt(SYS) / "my" / entityName).POST <@ (user1), write(("name" -> "mine"): JValue)) + personal.code should equal(201) + (personal.body \ "bank_id").extract[String] should equal(SYS) + val myList = makeGetRequest((dataAt(SYS) / "my" / entityName).GET <@ (user1)) + myList.code should equal(200) + (myList.body \ s"${entityName}_list").extract[List[JObject]].map(o => (o \ s"${entityName}_id").extract[String]) should contain(idOf(personal, entityName)) + makeDeleteRequest((dataAt(SYS) / "my" / entityName / idOf(personal, entityName)).DELETE <@ (user1)).code should equal(200) + + Then("the record can be deleted at SYS") + makeDeleteRequest((dataAt(SYS) / entityName / recordId).DELETE <@ (user1)).code should equal(200) + makeGetRequest((dataAt(SYS) / entityName / recordId).GET <@ (user1)).code should equal(404) + } finally cascadeDelete(SYS, dynamicEntityId) + } + + scenario("the definition's links point at the v7.0.0 data URLs", ApiEndpoint2, VersionOfApi) { + val entityName = newEntityName() + grant(SYS, canCreateDynamicEntityDefinition) + val created = makePostRequest(definitionsAt(SYS).POST <@ (user1), + write(flaggedDefinition(entityName, "has_public_access" -> true))) + created.code should equal(201) + try { + val hrefs = (created.body \ "_links" \ "related").extract[List[JObject]].map(l => (l \ "href").extract[String]) + hrefs should not be empty + all(hrefs) should startWith(s"/obp/v7.0.0/banks/$SYS/dynamic-entities/") + } finally cascadeDelete(SYS, (created.body \ "dynamic_entity_id").extract[String]) + } + + scenario("records of a bank level entity are served at that bank and name it", VersionOfApi) { + val entityName = newEntityName() + val bankId = testBankId1.value + val dynamicEntityId = createdWithFlags(bankId, entityName) + try { + val created = makePostRequest((dataAt(bankId) / entityName).POST <@ (user1), write(("name" -> "at a bank"): JValue)) + created.code should equal(201) + (created.body \ "bank_id").extract[String] should equal(bankId) + + And("the entity is not found in the system space") + makeGetRequest((dataAt(SYS) / entityName).GET <@ (user1)).code should equal(404) + makeDeleteRequest((dataAt(bankId) / entityName / idOf(created, entityName)).DELETE <@ (user1)).code should equal(200) + } finally cascadeDelete(bankId, dynamicEntityId) + } + + scenario("the row-level access list is served at SYS", VersionOfApi) { + val entityName = newEntityName() + val dynamicEntityId = createdWithFlags(SYS, entityName, "use_row_level_access" -> true) + try { + val created = makePostRequest((dataAt(SYS) / entityName).POST <@ (user1), write(("name" -> "shared"): JValue)) + created.code should equal(201) + val access = makeGetRequest((dataAt(SYS) / entityName / idOf(created, entityName) / "access").GET <@ (user1)) + access.code should equal(200) + } finally cascadeDelete(SYS, dynamicEntityId) + } + + scenario("an entity that does not exist is a 404", VersionOfApi) { + makeGetRequest((dataAt(SYS) / ("no_such_entity_" + UUID.randomUUID().toString.take(8).replace("-", ""))).GET <@ (user1)).code should equal(404) + } + } + + feature("SYS is only a space where an endpoint says so") { + + scenario("an endpoint that needs a real bank still answers 404 for SYS", VersionOfApi) { + grant(SYS, canGetDynamicEntityDefinitions) + When("the v4.0.0 bank level endpoint, which has not opted in, is called at SYS") + val response = makeGetRequest((baseRequest / "obp" / "v4.0.0" / "management" / "banks" / SYS / "dynamic-entities").GET <@ (user1)) + Then("SYS is looked up as a bank and not found") + response.code should equal(404) + errorOf(response) should include(BankNotFound) + } + } +} diff --git a/release_notes.md b/release_notes.md index ac7d5a79f0..6ce3de4e3d 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,60 @@ ### Most recent changes at top of file ``` Date Commit Action +24/09/2026 TBD RENAMED and RE-SCOPED: the Roles that gate a Dynamic Entity's + DEFINITION, completing the change below. Each System and BankLevel pair + is now one Role, granted at a bank's id or at SYS for the system space: + + CanCreateSystemLevelDynamicEntity, CanCreateBankLevelDynamicEntity + -> CanCreateDynamicEntityDefinition + CanUpdateSystemLevelDynamicEntity, CanUpdateBankLevelDynamicEntity + -> CanUpdateDynamicEntityDefinition + CanDeleteSystemLevelDynamicEntity, CanDeleteBankLevelDynamicEntity + -> CanDeleteDynamicEntityDefinition + CanGetSystemLevelDynamicEntities, CanGetBankLevelDynamicEntities + -> CanGetDynamicEntityDefinitions + CanBackupSystemDynamicEntity, CanBackupBankLevelDynamicEntity + -> CanBackupDynamicEntityDefinition + CanDeleteCascadeSystemDynamicEntity + -> CanDeleteCascadeDynamicEntityDefinition + + NEW in v7.0.0: one set of management endpoints for every space, + /management/banks/BANK_ID/dynamic-entities (list, create, update, delete, + backup, cascade delete), where BANK_ID is a bank's id or SYS. The Role is + checked at that BANK_ID, and every response carries bank_id, SYS included. + SYS is accepted as BANK_ID only by endpoints that declare it; everywhere + else it is still an unknown bank (404). + + NEW in v7.0.0: the records themselves, at + /obp/v7.0.0/banks/BANK_ID/dynamic-entities/ENTITY_NAME[/RECORD_ID], and the + my/, public/, community/ and .../access forms after dynamic-entities/. + Same checks and storage as /obp/dynamic-entity/, but every response carries + bank_id, SYS included. The unversioned URLs are unchanged. + + The older /management/system-dynamic-entities endpoints (v4.0.0, v6.0.0) + keep working and check the same Roles at SYS. + + NOTHING TO DO for an ordinary upgrade: a second migration renames the + stored Roles across Entitlements, Entitlement Requests, Consumer Scopes and + Group Role lists, moving system level ones to SYS. A system level Group the + first migration left behind because it also held a Definition Role now + moves to SYS, unless it holds a Role from outside Dynamic Entities. An + instance that runs with migration scripts disabled must re-grant by hand. + + FIXED, found while doing this: + - DELETE /management/diagnostics/dynamic-entities/orphaned-records treated + every system level record as orphaned, because records store SYS while + definitions call the system space None, and so deleted them all. It now + compares the two in the same form. + - GET /obp/v6.0.0/management/system-dynamic-entities reported a + record_count of 0 for every entity: it counted records with a NULL bank id. + - Backing up a system level entity (v6.0.0) always returned 403, because it + checked the entity's Get Record Role at the empty bank id, and it granted + that Role on the backup there too, where nothing reads it. Both use SYS. + - Updating a Dynamic Entity definition (v6.0.0) whose id does not exist in + that space answered 400 InvalidJsonFormat; it now answers 404 + DynamicEntityNotFoundByDynamicEntityId. + 24/09/2026 TBD RENAMED and RE-SCOPED: the Roles that gate a Dynamic Entity's records. A Role that was called CanCreateDynamicEntity_SystemCountry or CanCreateDynamicEntity_Country is now CanCreateDynamicEntityRecord_Country From a2472f15ba25d95eaec8ef2d2b9575e569437d92 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 25 Sep 2026 00:39:02 +0200 Subject: [PATCH 8/8] Turn of smartypants fancy characters --- .../main/scala/code/api/util/PegdownOptions.scala | 11 ++++------- .../test/scala/code/api/sweep/SweepFixtures.scala | 11 +++++++++-- .../test/scala/code/util/PegdownOptionsTest.scala | 12 ++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/PegdownOptions.scala b/obp-api/src/main/scala/code/api/util/PegdownOptions.scala index 4d49d114bd..48f918958c 100644 --- a/obp-api/src/main/scala/code/api/util/PegdownOptions.scala +++ b/obp-api/src/main/scala/code/api/util/PegdownOptions.scala @@ -35,20 +35,17 @@ import com.vladsch.flexmark.util.options.{DataHolder, MutableDataSet} object PegdownOptions { - private val OPTIONS: DataHolder = PegdownOptionsAdapter.flexmarkOptions(Extensions.ALL) + // Everything except SMARTYPANTS. That extension rewrites plain quotes, "--", "---" and "..." into + // typographic HTML entities such as –, which are not defined in XML. Descriptions are written + // as plain text and should be served as written. + private val OPTIONS: DataHolder = PegdownOptionsAdapter.flexmarkOptions(Extensions.ALL & ~Extensions.SMARTYPANTS) private val PARSER: Parser = Parser.builder(OPTIONS).build private val RENDERER: HtmlRenderer = HtmlRenderer.builder(OPTIONS).build def convertPegdownToHtmlTweaked(description: String): String = { val document = PARSER.parse(convertImgTag(description.stripMargin)) RENDERER.render(document) - .replaceAll("&ldquo", """) - .replaceAll("&rdquo", """) - .replaceAll("’", "'") - .replaceAll("‘;", "'") .replaceAll("&;", "&") - .replaceAll("‘", "'") - .replaceAll("…", "...") // not support make text bold that not at beginning of a line, so here manual convert to it to tag // .replaceAll("""\*\*(.+?)\*\*""", "$1") } diff --git a/obp-api/src/test/scala/code/api/sweep/SweepFixtures.scala b/obp-api/src/test/scala/code/api/sweep/SweepFixtures.scala index cd55aef282..31efb70d37 100644 --- a/obp-api/src/test/scala/code/api/sweep/SweepFixtures.scala +++ b/obp-api/src/test/scala/code/api/sweep/SweepFixtures.scala @@ -80,8 +80,15 @@ trait SweepFixtures { self: DefaultUsers => // roles whose backing entity may not exist in this database -- a grant that cannot be // made is not a reason to abandon the other several hundred. try { - val bankId = if (ApiRole.valueOf(role).requiresBankId) realBankId.getOrElse("") else "" - Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, role) + if (ApiRole.valueOf(role).requiresBankId) { + Entitlement.entitlement.vend.addEntitlement(realBankId.getOrElse(""), resourceUser1.userId, role) + // Dynamic Entity endpoints that name no bank check their Roles at the system space, SYS, + // so a caller holding every role must hold the bank-scoped ones there as well. + Entitlement.entitlement.vend.addEntitlement( + code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, role) + } else { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, role) + } } catch { case _: Exception => () } } Map("DirectLogin" -> s"token=${token1.value}") diff --git a/obp-api/src/test/scala/code/util/PegdownOptionsTest.scala b/obp-api/src/test/scala/code/util/PegdownOptionsTest.scala index 42f670fa26..4f1f9d97a0 100644 --- a/obp-api/src/test/scala/code/util/PegdownOptionsTest.scala +++ b/obp-api/src/test/scala/code/util/PegdownOptionsTest.scala @@ -485,4 +485,16 @@ Authentication is Mandatory""".stripMargin descriptionHtml3 contains("

Authentication is Mandatory

") should be (true) } + + "dashes, ellipses and quotes" should "be served as written, not as typographic entities" taggedAs FunctionsTag in { + val descriptionHtml = convertPegdownToHtmlTweaked( + """Only that Consumer can present the Consent JWT -- any other gets "ConsentNotFound"... it's pinned.""") + + // An XML parse is what failed on –, so it is the check that matters. + stringToNodeSeq(descriptionHtml) + descriptionHtml should include ("JWT -- any other") + descriptionHtml should include ("...") + descriptionHtml should not include ("–") + descriptionHtml should not include ("“") + } }