diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index f2905252ae..72f94bb144 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -86,7 +86,7 @@ jobs: echo "No maven-build.log found; skipping failure scan." exit 0 fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then + if grep -C 3 -n "\*\*\* FAILED \*\*\*" maven-build.log; then echo "Failing tests detected above." exit 1 else diff --git a/.gitignore b/.gitignore index 1f8aabc66e..219ca48427 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ project/project coursier metals.sbt obp-http4s-runner/src/main/resources/git.properties -test-results \ No newline at end of file +test-results +untracked_files/ diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index 38208d32d8..a3554e474c 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -73,6 +73,7 @@ object ApiTag { val apiTagFx = ResourceDocTag("FX") val apiTagMessage = ResourceDocTag("Customer-Message") val apiTagMetric = ResourceDocTag("Metric") + val apiTagMessageDoc = ResourceDocTag("Message-Doc") val apiTagDocumentation = ResourceDocTag("Documentation") val apiTagBerlinGroup = ResourceDocTag("Berlin-Group") val apiTagSigningBaskets = ResourceDocTag("Signing Baskets") @@ -105,6 +106,7 @@ object ApiTag { val apiTagDynamic = ResourceDocTag("Dynamic") val apiTagDynamicEntity = ResourceDocTag("Dynamic-Entity") val apiTagManageDynamicEntity = ResourceDocTag("Dynamic-Entity-Manage") + val apiTagPersonalDynamicEntity = ResourceDocTag("Personal-Dynamic-Entity") val apiTagDynamicEndpoint = ResourceDocTag("Dynamic-Endpoint") val apiTagManageDynamicEndpoint = ResourceDocTag("Dynamic-Endpoint-Manage") diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 87f28e693c..44fe84e8ef 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -75,6 +75,7 @@ object ErrorMessages { val DynamicDataNotFound = "OBP-09015: Dynamic Data not found. Please specify a valid value." val DuplicateQueryParameters = "OBP-09016: Duplicate Query Parameters are not allowed." val DuplicateHeaderKeys = "OBP-09017: Duplicate Header Keys are not allowed." + val InvalidDynamicEntityName = "OBP-09018: Invalid entity_name format. Entity names must be lowercase with underscores (snake_case), e.g. 'customer_preferences'. No uppercase letters or spaces allowed." // General messages (OBP-10XXX) @@ -649,6 +650,16 @@ object ErrorMessages { val CannotGetUserInvitation = "OBP-37882: Cannot get user invitation." val CannotFindUserInvitation = "OBP-37883: Cannot find user invitation." + // ABAC Rule related messages (OBP-38XXX) + val AbacRuleValidationFailed = "OBP-38001: ABAC rule validation failed. The rule code could not be validated." + val AbacRuleCompilationFailed = "OBP-38002: ABAC rule compilation failed. The rule code contains syntax errors or invalid Scala code." + val AbacRuleTypeMismatch = "OBP-38003: ABAC rule type mismatch. The rule code must return a Boolean value but returns a different type." + val AbacRuleSyntaxError = "OBP-38004: ABAC rule syntax error. The rule code contains invalid syntax." + val AbacRuleFieldReferenceError = "OBP-38005: ABAC rule field reference error. The rule code references fields or objects that do not exist." + val AbacRuleCodeEmpty = "OBP-38006: ABAC rule code must not be empty." + val AbacRuleNotFound = "OBP-38007: ABAC rule not found. Please specify a valid value for ABAC_RULE_ID." + val AbacRuleNotActive = "OBP-38008: ABAC rule is not active." + val AbacRuleExecutionFailed = "OBP-38009: ABAC rule execution failed. An error occurred while executing the rule." // Transaction Request related messages (OBP-40XXX) val InvalidTransactionRequestType = "OBP-40001: Invalid value for TRANSACTION_REQUEST_TYPE" 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 cde7dd1dd9..fbab12e006 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -578,7 +578,119 @@ object Glossary extends MdcLoggable { |""" ) - + glossaryItems += GlossaryItem( + title = "Connector.User.Authentication", + description = + s""" + |### Overview + | + |The property `connector.user.authentication` (default: `false`) controls whether OBP can authenticate a user via the Connector when they are not found locally. + | + |OBP always checks for users locally first. When this property is enabled and a user is not found locally (or exists but is from an external provider), OBP will attempt to authenticate them against an external identity provider or Core Banking System (CBS) via the Connector. + | + |### Configuration + | + |In your props file: + | + |``` + |connector.user.authentication=true + |``` + | + |### Behavior When Enabled (true) + | + |**1. Login Authentication Flow:** + | + |When a user attempts to log in: + | + |``` + |User Login Request + | │ + | ▼ + |┌─────────────────────────┐ + |│ 1. Check if user exists │ + |│ locally in OBP │ + |└───────────┬─────────────┘ + | │ + | ┌────────┼────────┬─────────────────┐ + | │ │ │ │ + | ▼ ▼ ▼ ▼ + |Found Found Found Not Found + |(local (external (external (and property + |provider) provider) provider enabled) + | │ property property │ + | │ disabled) enabled) │ + | │ │ │ │ + | ▼ ▼ ▼ ▼ + |┌────────┐ ┌────┐ ┌─────────────────────────┐ + |│Check │ │Fail│ │ 2. Call Connector: │ + |│local │ │ │ │ checkExternalUser │ + |│password│ │ │ │ Credentials() │ + |└───┬────┘ └────┘ └───────────┬─────────────┘ + | │ │ + | ▼ ┌────────┴────────┐ + | Success/ │ │ + | Failure ▼ ▼ + | Success Failure + | │ │ + | ▼ ▼ + | ┌─────────────┐ ┌─────────────┐ + | │Create local │ │Increment │ + | │AuthUser if │ │bad login │ + | │not exists │ │attempts │ + | └─────────────┘ └─────────────┘ + |``` + | + |**2. Username Uniqueness Validation:** + | + |During user signup, OBP checks if the username already exists in the external system by calling `checkExternalUserExists()`. + | + |**3. Auto Creation of Local Users:** + | + |If external authentication succeeds but the user doesn't exist locally, OBP automatically creates a local `AuthUser` record linked to the external provider. + | + |### Behavior When Disabled (false, default) + | + |* Users must exist locally in OBP's database + |* Authentication is performed against locally stored credentials + |* No connector calls are made for authentication + | + |### Required Connector Methods + | + |When enabled, your Connector must implement: + | + |* ${messageDocLinkRabbitMQ("obp.checkExternalUserCredentials")} : Validates username and password against external system. Returns `InboundExternalUser` with user details (sub, iss, email, name, userAuthContexts). + | + |* ${messageDocLinkRabbitMQ("obp.checkExternalUserExists")} : Checks if a username exists in the external system. Used during signup validation. + | + |### InboundExternalUser Response + | + |The connector should return user information including: + | + |* `sub`: Subject identifier (username) + |* `iss`: Issuer (provider identifier) + |* `email`: User's email address + |* `name`: User's display name + |* `userAuthContexts`: Optional list of auth contexts (e.g., customer numbers) + | + |### Use Cases + | + |**Enable when:** + |* You have an external identity provider (LDAP, Active Directory, OAuth provider) + |* User credentials are managed by the Core Banking System + |* You want single sign on with an existing user directory + | + |**Disable when:** + |* OBP manages all user authentication locally + |* You're using OBP's built in user management + |* You don't have an external authentication system + | + |### Related Properties + | + |* `connector`: Specifies which connector implementation to use + |* `connector.user.authcontext.read.in.login`: Read user auth contexts during login + | + |""" + ) @@ -3152,6 +3264,35 @@ object Glossary extends MdcLoggable { | |OBP generates ONLY the regular endpoints. No 'my' endpoints are created. Use this when the entity represents shared data that should not be user-scoped. | +|**Data Storage Differences:** +| +|Both personal and non-personal entities use the same database table (DynamicData), but the key difference is how user ownership is handled: +| +|When **hasPersonalEntity = true**: +| +|* Each record stores the UserId of the user who created it +|* The UserId is **actively used in all queries** to filter results +|* Users can only see, update, and delete their own records via 'my' endpoints +|* The 'my' endpoints **skip role checks** - user isolation provides the authorization +|* Cascade delete (deleting the entity definition and all data at once) is **not allowed** +| +|When **hasPersonalEntity = false**: +| +|* UserId may be stored for audit purposes but is **ignored in queries** +|* All authorized users see the same shared data +|* Role-based authorization is **required** (e.g., CanGetDynamicEntity_FooBar) +|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation +| +|**Summary table:** +| +|| Feature | hasPersonalEntity=true | hasPersonalEntity=false | +||---------|------------------------|-------------------------| +|| Data visibility | Per-user (isolated) | Shared (all users) | +|| UserId in queries | Yes (filters results) | No (ignored) | +|| 'my' endpoints | Generated | Not generated | +|| Authorization | User-scoped (no roles needed for 'my' endpoints) | Role-based | +|| Cascade delete | Blocked | Allowed | +| |**For bank-level entities**, endpoints include the bank ID: | |* POST /banks/BANK_ID/CustomerPreferences @@ -3249,11 +3390,72 @@ object Glossary extends MdcLoggable { | |**Note:** If hasPersonalEntity is set to false, no 'my' endpoints are generated. | -|**Management endpoints for Dynamic Entity definitions:** +|**Management endpoints for Dynamic Entity definitions (available from v4.0.0):** | |* GET /my/dynamic-entities - Get all Dynamic Entity definitions I created |* PUT /my/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition I created | +|**Discovery endpoint (available from v6.0.0):** +| +|* GET /personal-dynamic-entities/available - Discover all Dynamic Entities that support personal data storage +| +|This endpoint allows regular users (without admin roles) to discover which dynamic entities they can interact with for storing personal data via the /my/ENTITY_NAME endpoints. No special roles required - just needs to be logged in. +| +|**Response format for GET /my/dynamic-entities and GET /personal-dynamic-entities/available:** +| +|**v6.0.0 format (recommended):** +| +|The v6.0.0 response uses snake_case field names and an explicit `entity_name` field: +| +|```json +|{ +| "dynamic_entities": [ +| { +| "dynamic_entity_id": "abc-123-def", +| "entity_name": "CustomerPreferences", +| "user_id": "user-456", +| "bank_id": null, +| "has_personal_entity": true, +| "definition": { +| "description": "User preferences", +| "required": ["theme"], +| "properties": { +| "theme": {"type": "string"}, +| "language": {"type": "string"} +| } +| } +| } +| ] +|} +|``` +| +|**v4.0.0 format (legacy):** +| +|The v4.0.0 response uses camelCase field names and the **entity name is a dynamic key** (not a fixed property name): +| +|```json +|{ +| "dynamic_entities": [ +| { +| "CustomerPreferences": { +| "description": "User preferences", +| "required": ["theme"], +| "properties": { +| "theme": {"type": "string"}, +| "language": {"type": "string"} +| } +| }, +| "dynamicEntityId": "abc-123-def", +| "userId": "user-456", +| "hasPersonalEntity": true, +| "bankId": null +| } +| ] +|} +|``` +| +|To extract the entity name from the v4.0.0 format programmatically, find the key that is NOT one of the standard properties: dynamicEntityId, userId, hasPersonalEntity, bankId. +| |**Required roles:** | |* CanCreateSystemLevelDynamicEntity - To create system level dynamic entities @@ -3571,7 +3773,17 @@ object Glossary extends MdcLoggable { glossaryItems += GlossaryItem( title = "Dynamic linking (PSD2 context)", description = - s"""""".stripMargin) + s"""Dynamic linking is a security requirement under PSD2's Strong Customer Authentication (SCA) rules. + | + |When a payer initiates an electronic payment transaction, the authentication code must be dynamically linked to: + | + |1. **The amount** of the transaction + |2. **The payee** (recipient) of the transaction + | + |This means if either the amount or payee is modified after authentication, the authentication code becomes invalid. This protects against man-in-the-middle attacks where an attacker might try to redirect funds or change the payment amount after the user has authenticated. + | + |The requirement is specified in Article 97(2) of PSD2 and further detailed in the Regulatory Technical Standards (RTS) on SCA (Articles 5 and 6). + |""".stripMargin) glossaryItems += GlossaryItem( title = "TPP", diff --git a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala new file mode 100644 index 0000000000..6e095fb989 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala @@ -0,0 +1,269 @@ +package code.api.util + +import code.api.util.APIUtil.MessageDoc +import com.openbankproject.commons.util.ReflectUtils +import net.liftweb.json.JsonAST._ +import net.liftweb.json.JsonDSL._ + +import scala.reflect.runtime.universe._ + +/** + * Utility for generating JSON Schema from Scala case classes + * Used by the message-docs JSON Schema endpoint to provide machine-readable schemas + * for adapter code generation in any language. + */ +object JsonSchemaGenerator { + + /** + * Convert a list of MessageDoc to a complete JSON Schema document + */ + def messageDocsToJsonSchema(messageDocs: List[MessageDoc], connectorName: String): JObject = { + val allDefinitions = scala.collection.mutable.Map[String, JObject]() + + val messages = messageDocs.map { messageDoc => + val outboundType = ReflectUtils.getType(messageDoc.exampleOutboundMessage) + val inboundType = ReflectUtils.getType(messageDoc.exampleInboundMessage) + + // Collect all nested type definitions + collectDefinitions(outboundType, allDefinitions) + collectDefinitions(inboundType, allDefinitions) + + ("process" -> messageDoc.process) ~ + ("description" -> messageDoc.description) ~ + ("message_format" -> messageDoc.messageFormat) ~ + ("outbound_topic" -> messageDoc.outboundTopic) ~ + ("inbound_topic" -> messageDoc.inboundTopic) ~ + ("outbound_schema" -> typeToJsonSchema(outboundType)) ~ + ("inbound_schema" -> typeToJsonSchema(inboundType)) ~ + ("adapter_implementation" -> messageDoc.adapterImplementation.map { impl => + ("group" -> impl.group) ~ + ("suggested_order" -> JInt(BigInt(impl.suggestedOrder))) + }) + } + + ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ + ("title" -> s"$connectorName Message Schemas") ~ + ("description" -> s"JSON Schema definitions for $connectorName connector messages") ~ + ("type" -> "object") ~ + ("properties" -> ( + ("messages" -> ( + ("type" -> "array") ~ + ("items" -> messages) + )) + )) ~ + ("definitions" -> JObject(allDefinitions.toList.map { case (name, schema) => JField(name, schema) })) + } + + /** + * Convert a Scala Type to JSON Schema + */ + private def typeToJsonSchema(tpe: Type): JObject = { + tpe match { + case t if t =:= typeOf[String] => + ("type" -> "string") + + case t if t =:= typeOf[Int] => + ("type" -> "integer") ~ ("format" -> "int32") + + case t if t =:= typeOf[Long] => + ("type" -> "integer") ~ ("format" -> "int64") + + case t if t =:= typeOf[Double] => + ("type" -> "number") ~ ("format" -> "double") + + case t if t =:= typeOf[Float] => + ("type" -> "number") ~ ("format" -> "float") + + case t if t =:= typeOf[BigDecimal] || t =:= typeOf[scala.math.BigDecimal] => + ("type" -> "number") + + case t if t =:= typeOf[Boolean] => + ("type" -> "boolean") + + case t if t =:= typeOf[java.util.Date] => + ("type" -> "string") ~ ("format" -> "date-time") + + case t if t <:< typeOf[Option[_]] => + val innerType = t.typeArgs.head + typeToJsonSchema(innerType) + + case t if t <:< typeOf[List[_]] || t <:< typeOf[Seq[_]] || t <:< typeOf[scala.collection.immutable.List[_]] => + val itemType = t.typeArgs.head + ("type" -> "array") ~ ("items" -> typeToJsonSchema(itemType)) + + case t if t <:< typeOf[Map[_, _]] => + ("type" -> "object") ~ ("additionalProperties" -> typeToJsonSchema(t.typeArgs.last)) + + case t if isEnumType(t) => + val enumValues = getEnumValues(t) + ("type" -> "string") ~ ("enum" -> JArray(enumValues.map(JString(_)))) + + case t if isCaseClass(t) => + val typeName = getTypeName(t) + ("$ref" -> s"#/definitions/$typeName") + + case _ => + // Fallback for unknown types + ("type" -> "object") + } + } + + /** + * Collect all type definitions recursively + */ + private def collectDefinitions(tpe: Type, definitions: scala.collection.mutable.Map[String, JObject]): Unit = { + if (!isCaseClass(tpe) || isPrimitiveOrKnown(tpe)) return + + val typeName = getTypeName(tpe) + if (definitions.contains(typeName)) return + + val schema = caseClassToJsonSchema(tpe, definitions) + definitions += (typeName -> schema) + } + + /** + * Convert a case class to JSON Schema definition + */ + private def caseClassToJsonSchema(tpe: Type, definitions: scala.collection.mutable.Map[String, JObject]): JObject = { + try { + val constructor = ReflectUtils.getPrimaryConstructor(tpe) + val params = constructor.paramLists.flatten + + val properties = params.map { param => + val paramName = param.name.toString + val paramType = param.typeSignature + + // Recursively collect nested definitions + if (isCaseClass(paramType) && !isPrimitiveOrKnown(paramType)) { + collectDefinitions(paramType, definitions) + } + + // Handle List/Seq inner types + if (paramType <:< typeOf[List[_]] || paramType <:< typeOf[Seq[_]]) { + val innerType = paramType.typeArgs.headOption.getOrElse(typeOf[Any]) + if (isCaseClass(innerType) && !isPrimitiveOrKnown(innerType)) { + collectDefinitions(innerType, definitions) + } + } + + // Handle Option inner types + if (paramType <:< typeOf[Option[_]]) { + val innerType = paramType.typeArgs.headOption.getOrElse(typeOf[Any]) + if (isCaseClass(innerType) && !isPrimitiveOrKnown(innerType)) { + collectDefinitions(innerType, definitions) + } + } + + val propertySchema = typeToJsonSchema(paramType) + + // Add description from annotations if available + val description = getFieldDescription(param) + val schemaWithDesc = if (description.nonEmpty) { + propertySchema ~ ("description" -> description) + } else { + propertySchema + } + + JField(paramName, schemaWithDesc) + } + + // Determine required fields (non-Option types) + val requiredFields = params + .filterNot(p => p.typeSignature <:< typeOf[Option[_]]) + .map(_.name.toString) + + val baseSchema = ("type" -> "object") ~ ("properties" -> JObject(properties)) + + if (requiredFields.nonEmpty) { + baseSchema ~ ("required" -> JArray(requiredFields.map(JString(_)))) + } else { + baseSchema + } + } catch { + case e: Exception => + // Fallback for types we can't introspect + ("type" -> "object") ~ ("description" -> s"Schema generation failed: ${e.getMessage}") + } + } + + /** + * Get readable type name for schema definitions + */ + private def getTypeName(tpe: Type): String = { + val fullName = tpe.typeSymbol.fullName + // Remove package prefix, keep only class name + val simpleName = fullName.split("\\.").last + // Handle nested types + simpleName.replace("$", "") + } + + /** + * Check if type is a case class + */ + private def isCaseClass(tpe: Type): Boolean = { + tpe.typeSymbol.isClass && tpe.typeSymbol.asClass.isCaseClass + } + + /** + * Check if type is an enum + */ + private def isEnumType(tpe: Type): Boolean = { + // Check for common enum patterns in OBP + val typeName = tpe.typeSymbol.fullName + typeName.contains("enums.") || + (tpe.baseClasses.exists(_.fullName.contains("Enumeration")) && tpe.typeSymbol.isModuleClass) + } + + /** + * Get enum values if type is an enum + */ + private def getEnumValues(tpe: Type): List[String] = { + try { + // Try to get enum values through reflection + // This is a simplified version - might need enhancement for complex enums + List.empty[String] // Placeholder - enum extraction can be complex + } catch { + case _: Exception => List.empty[String] + } + } + + /** + * Check if type is primitive or commonly known type that shouldn't be expanded + */ + private def isPrimitiveOrKnown(tpe: Type): Boolean = { + tpe =:= typeOf[String] || + tpe =:= typeOf[Int] || + tpe =:= typeOf[Long] || + tpe =:= typeOf[Double] || + tpe =:= typeOf[Float] || + tpe =:= typeOf[Boolean] || + tpe =:= typeOf[BigDecimal] || + tpe =:= typeOf[java.util.Date] || + tpe <:< typeOf[Option[_]] || + tpe <:< typeOf[List[_]] || + tpe <:< typeOf[Seq[_]] || + tpe <:< typeOf[Map[_, _]] + } + + /** + * Extract field description from annotations or scaladoc (simplified) + */ + private def getFieldDescription(param: Symbol): String = { + // This is a placeholder - extracting scaladoc is complex + // Could be enhanced to read annotations or scaladoc comments + "" + } + + /** + * Generate a simplified single-message JSON Schema (for testing) + */ + def generateSchemaForType[T: TypeTag]: JObject = { + val tpe = typeOf[T] + val definitions = scala.collection.mutable.Map[String, JObject]() + collectDefinitions(tpe, definitions) + + ("$schema" -> "http://json-schema.org/draft-07/schema#") ~ + typeToJsonSchema(tpe) ~ + ("definitions" -> JObject(definitions.toList.map { case (name, schema) => JField(name, schema) })) + } +} \ No newline at end of file diff --git a/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala b/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala index eeba0a8aaa..08ef79ea69 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/APIMethods220.scala @@ -458,7 +458,7 @@ trait APIMethods220 { EmptyBody, messageDocsJson, List(InvalidConnector, UnknownError), - List(apiTagDocumentation, apiTagApi) + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi) ) lazy val getMessageDocs: OBPEndpoint = { diff --git a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala index 20a28c5987..719e82a049 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala @@ -3157,7 +3157,7 @@ trait APIMethods310 { EmptyBody, EmptyBody, List(UnknownError), - List(apiTagDocumentation, apiTagApi) + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi) ) lazy val getMessageDocsSwagger: OBPEndpoint = { diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index 1e52b797a6..123c6390f3 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -2245,7 +2245,12 @@ trait APIMethods400 extends MdcLoggable { | |This endpoint returns all available reference types (both static OBP entities and dynamic entities) with example values showing the correct format. | - |Note: if you set `hasPersonalEntity` = false, then OBP will not generate the CRUD my FooBar endpoints. + |**The hasPersonalEntity flag:** + | + |* If `hasPersonalEntity` = **true** (default): OBP generates both regular endpoints AND personal 'my' endpoints. Data is user-scoped - each user only sees their own records via 'my' endpoints. + |* If `hasPersonalEntity` = **false**: OBP generates ONLY regular endpoints (no 'my' endpoints). Data is shared - all authorized users see the same records. + | + |This flag also affects authorization (role-based vs user-scoped) and whether cascade delete is allowed. See the Dynamic-Entities glossary for full details on data storage differences. | |$dynamicEntityNamingExplanation | @@ -2448,7 +2453,12 @@ trait APIMethods400 extends MdcLoggable { | |This endpoint returns all available reference types (both static OBP entities and dynamic entities) with example values showing the correct format. | - |Note: if you set `hasPersonalEntity` = false, then OBP will not generate the CRUD my FooBar endpoints. + |**The hasPersonalEntity flag:** + | + |* If `hasPersonalEntity` = **true** (default): OBP generates both regular endpoints AND personal 'my' endpoints. Data is user-scoped - each user only sees their own records via 'my' endpoints. + |* If `hasPersonalEntity` = **false**: OBP generates ONLY regular endpoints (no 'my' endpoints). Data is shared - all authorized users see the same records. + | + |This flag also affects authorization (role-based vs user-scoped) and whether cascade delete is allowed. See the Dynamic-Entities glossary for full details on data storage differences. | |$dynamicEntityNamingExplanation | diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 9b1ca4dbae..6f436a0233 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -13,8 +13,10 @@ import code.api.util.ApiTag._ import code.api.util.ErrorMessages.{$AuthenticatedUserIsRequired, InvalidDateFormat, InvalidJsonFormat, UnknownError, DynamicEntityOperationNotAllowed, _} import code.api.util.FutureUtil.EndpointContext import code.api.util.Glossary +import code.api.util.JsonSchemaGenerator import code.api.util.NewStyle.HttpCode import code.api.util.{APIUtil, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, RateLimitingUtil} +import net.liftweb.json import code.api.util.NewStyle.function.extractQueryParams import code.api.util.newstyle.ViewNewStyle import code.api.v3_0_0.JSONFactory300 @@ -28,7 +30,7 @@ import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500} import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson} -import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics @@ -52,7 +54,8 @@ import com.openbankproject.commons.model._ import com.openbankproject.commons.model.enums.DynamicEntityOperation._ import com.openbankproject.commons.model.enums.UserAttributeType import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.{Empty, Failure, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} +import net.liftweb.util.Helpers.tryo import org.apache.commons.lang3.StringUtils import net.liftweb.http.provider.HTTPParam import net.liftweb.http.rest.RestHelper @@ -4554,13 +4557,24 @@ trait APIMethods600 { | |Each dynamic entity in the response includes a `record_count` field showing how many data records exist for that entity. | + |This v6.0.0 endpoint returns snake_case field names and an explicit `entity_name` field. + | |For more information see ${Glossary.getGlossaryItemLink( "Dynamic-Entities" )} """, EmptyBody, - ListResult( - "dynamic_entities", - List(dynamicEntityResponseBodyExample) + DynamicEntitiesWithCountJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], + record_count = 42 + ) + ) ), List( $AuthenticatedUserIsRequired, @@ -4581,16 +4595,82 @@ trait APIMethods600 { ) } yield { val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) - val jObjectsWithCounts = listCommons.map { entity => + val entitiesWithCounts = listCommons.map { entity => 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) ) - entity.jValue.asInstanceOf[JObject] ~ ("record_count" -> recordCount) + (entity, recordCount) + } + ( + JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts), + HttpCode.`200`(cc.callContext) + ) + } + } + } + + staticResourceDocs += ResourceDoc( + getBankLevelDynamicEntities, + implementedInApiVersion, + nameOf(getBankLevelDynamicEntities), + "GET", + "/management/banks/BANK_ID/dynamic-entities", + "Get Bank Level Dynamic Entities", + s"""Get all Bank Level Dynamic Entities for one bank with record counts. + | + |Each dynamic entity in the response includes a `record_count` field showing how many data records exist for that entity. + | + |This v6.0.0 endpoint returns snake_case field names and an explicit `entity_name` field. + | + |For more information see ${Glossary.getGlossaryItemLink( + "Dynamic-Entities" + )} """, + EmptyBody, + DynamicEntitiesWithCountJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = Some("gh.29.uk"), + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], + record_count = 42 + ) + ) + ), + List( + $BankNotFound, + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canGetBankLevelDynamicEntities)) + ) + + lazy val getBankLevelDynamicEntities: OBPEndpoint = { + case "management" :: "banks" :: bankId :: "dynamic-entities" :: Nil JsonGet req => { + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + dynamicEntities <- Future( + NewStyle.function.getDynamicEntities(Some(bankId), false) + ) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) + val entitiesWithCounts = listCommons.map { entity => + val recordCount = DynamicData.count( + By(DynamicData.DynamicEntityName, entity.entityName), + By(DynamicData.IsPersonalEntity, false), + By(DynamicData.BankId, bankId) + ) + (entity, recordCount) } ( - ListResult("dynamic_entities", jObjectsWithCounts), + JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts), HttpCode.`200`(cc.callContext) ) } @@ -4611,6 +4691,423 @@ trait APIMethods600 { box.openOrThrowException(s"$UnknownError ") } + // Helper method for creating dynamic entities with v6.0.0 response format + private def createDynamicEntityV600( + cc: CallContext, + dynamicEntity: DynamicEntityCommons + ) = { + for { + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity( + dynamicEntity, + cc.callContext + ) + // Grant the CRUD roles to the logged-in user + crudRoles = List( + DynamicEntityInfo.canCreateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canUpdateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canGetRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canDeleteRole(result.entityName, dynamicEntity.bankId) + ) + } yield { + crudRoles.map(role => + Entitlement.entitlement.vend.addEntitlement( + dynamicEntity.bankId.getOrElse(""), + cc.userId, + role.toString() + ) + ) + val commonsData: DynamicEntityCommons = result + ( + JSONFactory600.createMyDynamicEntitiesJson(List(commonsData)).dynamic_entities.head, + HttpCode.`201`(cc.callContext) + ) + } + } + + // Helper method for updating dynamic entities with v6.0.0 response format + private def updateDynamicEntityV600( + cc: CallContext, + dynamicEntity: DynamicEntityCommons + ) = { + for { + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity( + dynamicEntity, + cc.callContext + ) + } yield { + val commonsData: DynamicEntityCommons = result + ( + JSONFactory600.createMyDynamicEntitiesJson(List(commonsData)).dynamic_entities.head, + HttpCode.`200`(cc.callContext) + ) + } + } + + staticResourceDocs += ResourceDoc( + createSystemDynamicEntity, + implementedInApiVersion, + nameOf(createSystemDynamicEntity), + "POST", + "/management/system-dynamic-entities", + "Create System Level Dynamic Entity", + s"""Create a system level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "customer_preferences", + | "has_personal_entity": true, + | "schema": { + | "description": "User preferences", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"} + | } + | } + |} + |``` + | + |**Important:** + |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + |* Each property MUST include an `example` field with a valid example value. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + CreateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canCreateSystemLevelDynamicEntity)) + ) + + // v6.0.0 entity names must be lowercase with underscores (snake_case) + private val validEntityNamePattern = "^[a-z][a-z0-9_]*$".r.pattern + + private def validateEntityNameV600(entityName: String, callContext: Option[CallContext]): Future[Unit] = { + if (validEntityNamePattern.matcher(entityName).matches()) { + Future.successful(()) + } else { + Future.failed(new RuntimeException(s"$InvalidDynamicEntityName Current value: '$entityName'")) + } + } + + lazy val createSystemDynamicEntity: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: Nil JsonPost json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) + internalJson = JSONFactory600.convertV600RequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, None, cc.userId, None) + result <- createDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + createBankLevelDynamicEntity, + implementedInApiVersion, + nameOf(createBankLevelDynamicEntity), + "POST", + "/management/banks/BANK_ID/dynamic-entities", + "Create Bank Level Dynamic Entity", + s"""Create a bank level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "customer_preferences", + | "has_personal_entity": true, + | "schema": { + | "description": "User preferences", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"} + | } + | } + |} + |``` + | + |**Important:** + |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + |* Each property MUST include an `example` field with a valid example value. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + CreateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = Some("gh.29.uk"), + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $BankNotFound, + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canCreateBankLevelDynamicEntity)) + ) + + lazy val createBankLevelDynamicEntity: OBPEndpoint = { + case "management" :: "banks" :: bankId :: "dynamic-entities" :: Nil JsonPost json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) + internalJson = JSONFactory600.convertV600RequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, None, cc.userId, Some(bankId)) + result <- createDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + updateSystemDynamicEntity, + implementedInApiVersion, + nameOf(updateSystemDynamicEntity), + "PUT", + "/management/system-dynamic-entities/DYNAMIC_ENTITY_ID", + "Update System Level Dynamic Entity", + s"""Update a system level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "customer_preferences", + | "has_personal_entity": true, + | "schema": { + | "description": "User preferences updated", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"}, + | "notifications_enabled": {"type": "boolean", "example": "true"} + | } + | } + |} + |``` + | + |**Important:** The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canUpdateSystemDynamicEntity)) + ) + + lazy val updateSystemDynamicEntity: OBPEndpoint = { + case "management" :: "system-dynamic-entities" :: dynamicEntityId :: Nil JsonPut json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, None) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + updateBankLevelDynamicEntity, + implementedInApiVersion, + nameOf(updateBankLevelDynamicEntity), + "PUT", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update Bank Level Dynamic Entity", + s"""Update a bank level Dynamic Entity. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "customer_preferences", + | "has_personal_entity": true, + | "schema": { + | "description": "User preferences updated", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"}, + | "notifications_enabled": {"type": "boolean", "example": "true"} + | } + | } + |} + |``` + | + |**Important:** The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + | + |For more information see ${Glossary.getGlossaryItemLink("Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = Some("gh.29.uk"), + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $BankNotFound, + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi), + Some(List(canUpdateBankLevelDynamicEntity)) + ) + + lazy val updateBankLevelDynamicEntity: OBPEndpoint = { + case "management" :: "banks" :: bankId :: "dynamic-entities" :: dynamicEntityId :: Nil JsonPut json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, Some(bankId)) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + staticResourceDocs += ResourceDoc( + updateMyDynamicEntity, + implementedInApiVersion, + nameOf(updateMyDynamicEntity), + "PUT", + "/my/dynamic-entities/DYNAMIC_ENTITY_ID", + "Update My Dynamic Entity", + s"""Update a Dynamic Entity that I created. + | + |This v6.0.0 endpoint accepts and returns snake_case field names with an explicit `entity_name` field. + | + |**Request format:** + |```json + |{ + | "entity_name": "customer_preferences", + | "has_personal_entity": true, + | "schema": { + | "description": "User preferences updated", + | "required": ["theme"], + | "properties": { + | "theme": {"type": "string", "example": "dark"}, + | "language": {"type": "string", "example": "en"}, + | "notifications_enabled": {"type": "boolean", "example": "true"} + | } + | } + |} + |``` + | + |**Important:** The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. + | + |For more information see ${Glossary.getGlossaryItemLink("My-Dynamic-Entities")}""", + UpdateDynamicEntityRequestJsonV600( + entity_name = "customer_preferences", + has_personal_entity = Some(true), + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "example": "dark"}, "language": {"type": "string", "example": "en"}, "notifications_enabled": {"type": "boolean", "example": "true"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi) + ) + + lazy val updateMyDynamicEntity: OBPEndpoint = { + case "my" :: "dynamic-entities" :: dynamicEntityId :: Nil JsonPut json -> _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + // Verify the user owns this dynamic entity + existingEntity <- Future( + NewStyle.function.getDynamicEntitiesByUserId(cc.userId).find(_.dynamicEntityId.contains(dynamicEntityId)) + ) + _ <- Helper.booleanToFuture(s"$DynamicEntityNotFoundByDynamicEntityId dynamicEntityId = $dynamicEntityId", cc = cc.callContext) { + existingEntity.isDefined + } + request <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc.callContext) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, existingEntity.get.bankId) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + staticResourceDocs += ResourceDoc( deleteSystemDynamicEntityCascade, implementedInApiVersion, @@ -5531,7 +6028,7 @@ trait APIMethods600 { validateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { json.extract[ValidateAbacRuleJsonV600] } - _ <- NewStyle.function.tryons(s"Rule code must not be empty", 400, callContext) { + _ <- NewStyle.function.tryons(s"$AbacRuleCodeEmpty", 400, callContext) { validateJson.rule_code.trim.nonEmpty } validationResult <- Future { @@ -5544,28 +6041,40 @@ trait APIMethods600 { case Failure(errorMsg, _, _) => // Extract error details from the error message val cleanError = errorMsg.replace("Invalid ABAC rule code: ", "").replace("Failed to compile ABAC rule: ", "") + + // Determine the proper OBP error message and error type + val (obpErrorMessage, errorType) = if (cleanError.toLowerCase.contains("type mismatch") || cleanError.toLowerCase.contains("found:") && cleanError.toLowerCase.contains("required: boolean")) { + (AbacRuleTypeMismatch, "TypeError") + } else if (cleanError.toLowerCase.contains("syntax") || cleanError.toLowerCase.contains("parse")) { + (AbacRuleSyntaxError, "SyntaxError") + } else if (cleanError.toLowerCase.contains("not found") || cleanError.toLowerCase.contains("not a member")) { + (AbacRuleFieldReferenceError, "FieldReferenceError") + } else if (cleanError.toLowerCase.contains("compilation failed") || cleanError.toLowerCase.contains("reflective compilation has failed")) { + (AbacRuleCompilationFailed, "CompilationError") + } else { + (AbacRuleValidationFailed, "ValidationError") + } + Full(ValidateAbacRuleFailureJsonV600( valid = false, error = cleanError, - message = "Rule validation failed", + message = obpErrorMessage, details = ValidateAbacRuleErrorDetailsJsonV600( - error_type = if (cleanError.toLowerCase.contains("syntax")) "SyntaxError" - else if (cleanError.toLowerCase.contains("type")) "TypeError" - else "CompilationError" + error_type = errorType ) )) case Empty => Full(ValidateAbacRuleFailureJsonV600( valid = false, error = "Unknown validation error", - message = "Rule validation failed", + message = AbacRuleValidationFailed, details = ValidateAbacRuleErrorDetailsJsonV600( error_type = "UnknownError" ) )) } } map { - unboxFullOrFail(_, callContext, "Validation failed", 400) + unboxFullOrFail(_, callContext, AbacRuleValidationFailed, 400) } } yield { (validationResult, HttpCode.`200`(callContext)) @@ -6293,6 +6802,214 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getMessageDocsJsonSchema, + implementedInApiVersion, + nameOf(getMessageDocsJsonSchema), + "GET", + "/message-docs/CONNECTOR/json-schema", + "Get Message Docs as JSON Schema", + """Returns message documentation as JSON Schema format for code generation in any language. + | + |This endpoint provides machine-readable schemas instead of just examples, making it ideal for: + |- AI-powered code generation + |- Automatic adapter creation in multiple languages + |- Type-safe client generation with tools like quicktype + | + |**Supported Connectors:** + |- rabbitmq_vOct2024 - RabbitMQ connector message schemas + |- rest_vMar2019 - REST connector message schemas + |- akka_vDec2018 - Akka connector message schemas + |- kafka_vMay2019 - Kafka connector message schemas (if available) + | + |**Code Generation Examples:** + | + |Generate Scala code with Circe: + |```bash + |curl https://api.../message-docs/rabbitmq_vOct2024/json-schema > schemas.json + |quicktype -s schema schemas.json -o Messages.scala --framework circe + |``` + | + |Generate Python code: + |```bash + |quicktype -s schema schemas.json -o messages.py --lang python + |``` + | + |Generate TypeScript code: + |```bash + |quicktype -s schema schemas.json -o messages.ts --lang typescript + |``` + | + |**Schema Structure:** + |Each message includes: + |- `process` - The connector method name (e.g., "obp.getAdapterInfo") + |- `description` - Human-readable description of what the message does + |- `outbound_schema` - JSON Schema for request messages (OBP-API -> Adapter) + |- `inbound_schema` - JSON Schema for response messages (Adapter -> OBP-API) + | + |All nested type definitions are included in the `definitions` section for reuse. + | + |**Authentication:** + |This endpoint is publicly accessible (no authentication required) to facilitate adapter development. + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + InvalidConnector, + UnknownError + ), + List(apiTagMessageDoc, apiTagDocumentation, apiTagApi) + ) + + lazy val getMessageDocsJsonSchema: OBPEndpoint = { + case "message-docs" :: connector :: "json-schema" :: Nil JsonGet _ => { + cc => { + implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- anonymousAccess(cc) + cacheKey = s"message-docs-json-schema-$connector" + cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) + jsonSchema <- if (cacheValueFromRedis.isDefined) { + NewStyle.function.tryons(s"$UnknownError Cannot parse cached JSON Schema.", 400, callContext) { + json.parse(cacheValueFromRedis.get).asInstanceOf[JObject] + } + } else { + NewStyle.function.tryons(s"$UnknownError Cannot generate JSON Schema.", 400, callContext) { + val connectorObjectBox = tryo{Connector.getConnectorInstance(connector)} + val connectorObject = unboxFullOrFail( + connectorObjectBox, + callContext, + s"$InvalidConnector Current input is: $connector. Valid connectors include: rabbitmq_vOct2024, rest_vMar2019, akka_vDec2018" + ) + val schema = JsonSchemaGenerator.messageDocsToJsonSchema( + connectorObject.messageDocs.toList, + connector + ) + val schemaString = json.compactRender(schema) + Caching.setStaticSwaggerDocCache(cacheKey, schemaString) + schema + } + } + } yield { + (jsonSchema, HttpCode.`200`(callContext)) + } + } + } + } + + staticResourceDocs += ResourceDoc( + getMyDynamicEntities, + implementedInApiVersion, + nameOf(getMyDynamicEntities), + "GET", + "/my/dynamic-entities", + "Get My Dynamic Entities", + s"""Get all Dynamic Entity definitions I created. + | + |This v6.0.0 endpoint returns a cleaner response format with: + |* snake_case field names (dynamic_entity_id, user_id, bank_id, has_personal_entity) + |* An explicit entity_name field instead of using the entity name as a dynamic JSON key + |* The entity schema in a separate definition object + | + |For more information see ${Glossary.getGlossaryItemLink( + "My-Dynamic-Entities" + )}""", + EmptyBody, + MyDynamicEntitiesJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ) + ) + ), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagManageDynamicEntity, apiTagApi) + ) + + lazy val getMyDynamicEntities: OBPEndpoint = { + case "my" :: "dynamic-entities" :: Nil JsonGet req => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + dynamicEntities <- Future( + NewStyle.function.getDynamicEntitiesByUserId(cc.userId) + ) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities + ( + JSONFactory600.createMyDynamicEntitiesJson(listCommons), + HttpCode.`200`(cc.callContext) + ) + } + } + } + + staticResourceDocs += ResourceDoc( + getAvailablePersonalDynamicEntities, + implementedInApiVersion, + nameOf(getAvailablePersonalDynamicEntities), + "GET", + "/personal-dynamic-entities/available", + "Get Available Personal Dynamic Entities", + s"""Get all Dynamic Entities that support personal data storage (hasPersonalEntity == true). + | + |This endpoint allows regular users (without admin roles) to discover which dynamic entities + |they can interact with for storing personal data via the /my/ENTITY_NAME endpoints. + | + |Authentication: User must be logged in (no special roles required). + | + |Use case: Portals and apps can show users what personal data types are available + |without needing admin access to view all dynamic entity definitions. + | + |For more information see ${Glossary.getGlossaryItemLink("My-Dynamic-Entities")}""", + EmptyBody, + MyDynamicEntitiesJsonV600( + dynamic_entities = List( + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = "abc-123-def", + entity_name = "customer_preferences", + user_id = "user-456", + bank_id = None, + has_personal_entity = true, + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + ) + ) + ), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagDynamicEntity, apiTagPersonalDynamicEntity, apiTagApi) + ) + + lazy val getAvailablePersonalDynamicEntities: OBPEndpoint = { + case "personal-dynamic-entities" :: "available" :: Nil JsonGet req => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + // Get all dynamic entities (system and bank level) + allDynamicEntities <- Future( + NewStyle.function.getDynamicEntities(None, false) ++ + NewStyle.function.getDynamicEntities(None, true) + ) + } yield { + // Filter to only those with hasPersonalEntity == true + val personalEntities: List[DynamicEntityCommons] = allDynamicEntities.filter(_.hasPersonalEntity) + ( + JSONFactory600.createMyDynamicEntitiesJson(personalEntities), + HttpCode.`200`(cc.callContext) + ) + } + } + } + } } 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 55a92ef0f7..fed14e065a 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 @@ -486,6 +486,50 @@ case class AbacPoliciesJsonV600( policies: List[AbacPolicyJsonV600] ) +// Dynamic Entity definition with fully predictable structure (v6.0.0 format) +// No dynamic keys - entity name is an explicit field, schema describes the structure +case class DynamicEntityDefinitionJsonV600( + dynamic_entity_id: String, + entity_name: String, + user_id: String, + bank_id: Option[String], + has_personal_entity: Boolean, + schema: net.liftweb.json.JsonAST.JObject +) + +case class MyDynamicEntitiesJsonV600( + dynamic_entities: List[DynamicEntityDefinitionJsonV600] +) + +// Management version includes record_count for admin visibility +case class DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id: String, + entity_name: String, + user_id: String, + bank_id: Option[String], + has_personal_entity: Boolean, + schema: net.liftweb.json.JsonAST.JObject, + record_count: Long +) + +case class DynamicEntitiesWithCountJsonV600( + dynamic_entities: List[DynamicEntityDefinitionWithCountJsonV600] +) + +// Request format for creating a dynamic entity (v6.0.0 with snake_case) +case class CreateDynamicEntityRequestJsonV600( + entity_name: String, + has_personal_entity: Option[Boolean], // defaults to true if not provided + schema: net.liftweb.json.JsonAST.JObject +) + +// Request format for updating a dynamic entity (v6.0.0 with snake_case) +case class UpdateDynamicEntityRequestJsonV600( + entity_name: String, + has_personal_entity: Option[Boolean], + schema: net.liftweb.json.JsonAST.JObject +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( @@ -1294,4 +1338,145 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { redis_available = redisAvailable ) } + + /** + * Create v6.0.0 response for GET /my/dynamic-entities + * + * Fully predictable structure with no dynamic keys. + * Entity name is an explicit field, schema describes the structure. + * + * Response format: + * { + * "dynamic_entities": [ + * { + * "dynamic_entity_id": "abc-123", + * "entity_name": "CustomerPreferences", + * "user_id": "user-456", + * "bank_id": null, + * "has_personal_entity": true, + * "schema": { ... } + * } + * ] + * } + */ + def createMyDynamicEntitiesJson(dynamicEntities: List[code.dynamicEntity.DynamicEntityCommons]): MyDynamicEntitiesJsonV600 = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.parse + + MyDynamicEntitiesJsonV600( + dynamic_entities = dynamicEntities.map { entity => + // metadataJson contains the full internal format: { "EntityName": { schema }, "hasPersonalEntity": true } + // We need to extract just the schema part using the entity name as key + val fullJson = parse(entity.metadataJson).asInstanceOf[JObject] + val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) + + // Validate that the dynamic key matches entity_name + val dynamicKeyName = fullJson.obj.find(_.name != "hasPersonalEntity").map(_.name) + if (dynamicKeyName.exists(_ != entity.entityName)) { + throw new IllegalStateException( + s"Dynamic entity key mismatch: stored entityName='${entity.entityName}' but dynamic key='${dynamicKeyName.getOrElse("none")}'" + ) + } + + val schemaObj = schemaOption.getOrElse( + throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") + ) + + DynamicEntityDefinitionJsonV600( + dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), + entity_name = entity.entityName, + user_id = entity.userId, + bank_id = entity.bankId, + has_personal_entity = entity.hasPersonalEntity, + schema = schemaObj + ) + } + ) + } + + /** + * Create v6.0.0 response for management GET endpoints (includes record_count) + */ + def createDynamicEntitiesWithCountJson( + entitiesWithCounts: List[(code.dynamicEntity.DynamicEntityCommons, Long)] + ): DynamicEntitiesWithCountJsonV600 = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.parse + + DynamicEntitiesWithCountJsonV600( + dynamic_entities = entitiesWithCounts.map { case (entity, recordCount) => + // metadataJson contains the full internal format: { "EntityName": { schema }, "hasPersonalEntity": true } + // We need to extract just the schema part using the entity name as key + val fullJson = parse(entity.metadataJson).asInstanceOf[JObject] + val schemaOption = fullJson.obj.find(_.name == entity.entityName).map(_.value.asInstanceOf[JObject]) + + // Validate that the dynamic key matches entity_name + val dynamicKeyName = fullJson.obj.find(_.name != "hasPersonalEntity").map(_.name) + if (dynamicKeyName.exists(_ != entity.entityName)) { + throw new IllegalStateException( + s"Dynamic entity key mismatch: stored entityName='${entity.entityName}' but dynamic key='${dynamicKeyName.getOrElse("none")}'" + ) + } + + val schema = schemaOption.getOrElse( + throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") + ) + + DynamicEntityDefinitionWithCountJsonV600( + dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), + entity_name = entity.entityName, + user_id = entity.userId, + bank_id = entity.bankId, + has_personal_entity = entity.hasPersonalEntity, + schema = schema, + record_count = recordCount + ) + } + ) + } + + /** + * Convert v6.0.0 request format to the internal JObject format expected by DynamicEntityCommons.apply + * + * Input (v6.0.0): + * { + * "entity_name": "CustomerPreferences", + * "has_personal_entity": true, + * "schema": { ... } + * } + * + * Output (internal): + * { + * "CustomerPreferences": { ... schema ... }, + * "hasPersonalEntity": true + * } + */ + def convertV600RequestToInternal(request: CreateDynamicEntityRequestJsonV600): net.liftweb.json.JsonAST.JObject = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.JsonDSL._ + + val hasPersonalEntity = request.has_personal_entity.getOrElse(true) + + // Build the internal format: entity name as dynamic key + hasPersonalEntity + JObject( + JField(request.entity_name, request.schema) :: + JField("hasPersonalEntity", JBool(hasPersonalEntity)) :: + Nil + ) + } + + def convertV600UpdateRequestToInternal(request: UpdateDynamicEntityRequestJsonV600): net.liftweb.json.JsonAST.JObject = { + import net.liftweb.json.JsonAST._ + import net.liftweb.json.JsonDSL._ + + val hasPersonalEntity = request.has_personal_entity.getOrElse(true) + + // Build the internal format: entity name as dynamic key + hasPersonalEntity + JObject( + JField(request.entity_name, request.schema) :: + JField("hasPersonalEntity", JBool(hasPersonalEntity)) :: + Nil + ) + } + } diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index b7047e4e30..127d7fb8f8 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -12,6 +12,13 @@ import net.liftweb.mapper._ import net.liftweb.util.Helpers.tryo import org.apache.commons.lang3.StringUtils +/** + * Note on IsPersonalEntity flag: + * The IsPersonalEntity flag indicates HOW a record was created (via /my/ endpoint or not), + * but is NOT used as a filter when querying personal data. The /my/ endpoints return all + * records belonging to the current user (filtered by UserId), regardless of IsPersonalEntity value. + * This provides a unified view of a user's data whether it was created via /my/ or non-/my/ endpoints. + */ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonFormats{ override def save(bankId: Option[String], entityName: String, requestBody: JObject, userId: Option[String], isPersonalEntity: Boolean): Box[DynamicDataT] = { val idName = getIdName(entityName) @@ -40,7 +47,7 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm if(bankId.isEmpty && !isPersonalEntity ){ //isPersonalEntity == false, get all the data, no need for specific userId. //forced the empty also to a error here. this is get Dynamic by Id, if it return Empty, better show the error in this level. DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.UserId, userId.getOrElse(null)), By(DynamicData.IsPersonalEntity, false), @@ -49,12 +56,11 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id") } - } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId. + } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get the data for specific userId (regardless of how it was created). DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.UserId, userId.getOrElse(null)), - By(DynamicData.IsPersonalEntity, true), NullRef(DynamicData.BankId) ) match { case Full(dynamicData) => Full(dynamicData) @@ -63,7 +69,7 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm } else if(bankId.isDefined && !isPersonalEntity ){ //isPersonalEntity == false, get all the data, no need for specific userId. //forced the empty also to a error here. this is get Dynamic by Id, if it return Empty, better show the error in this level. DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.IsPersonalEntity, false), By(DynamicData.BankId, bankId.get), @@ -71,19 +77,18 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id, bankId= ${bankId.get}") } - }else{ //isPersonalEntity == true, get all the data for specific userId. + }else{ //isPersonalEntity == true, get the data for specific userId (regardless of how it was created). DynamicData.find( - By(DynamicData.DynamicDataId, id), + By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), By(DynamicData.BankId, bankId.get), - By(DynamicData.UserId, userId.get), - By(DynamicData.IsPersonalEntity, true) + By(DynamicData.UserId, userId.get) ) match { case Full(dynamicData) => Full(dynamicData) case _ => Failure(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id, bankId= ${bankId.get}, userId = ${userId.get}") } } - + } override def getAllDataJson(bankId: Option[String], entityName: String, userId: Option[String], isPersonalEntity: Boolean): List[JObject] = { @@ -98,14 +103,13 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm By(DynamicData.DynamicEntityName, entityName), By(DynamicData.IsPersonalEntity, false), NullRef(DynamicData.BankId), - ) - } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId. + ) + } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId (regardless of how it was created). DynamicData.findAll( By(DynamicData.DynamicEntityName, entityName), By(DynamicData.UserId, userId.getOrElse(null)), - By(DynamicData.IsPersonalEntity, true), NullRef(DynamicData.BankId) - ) + ) } else if(bankId.isDefined && !isPersonalEntity){ //isPersonalEntity == false, get all the data, no need for specific userId. DynamicData.findAll( By(DynamicData.DynamicEntityName, entityName), @@ -113,11 +117,10 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm By(DynamicData.BankId, bankId.get), ) }else{ - DynamicData.findAll(//isPersonalEntity == true, get all the data for specific userId. + DynamicData.findAll(//isPersonalEntity == true, get all the data for specific userId (regardless of how it was created). By(DynamicData.DynamicEntityName, entityName), By(DynamicData.BankId, bankId.get), - By(DynamicData.UserId, userId.getOrElse(null)), - By(DynamicData.IsPersonalEntity, true) + By(DynamicData.UserId, userId.getOrElse(null)) ) } } @@ -139,18 +142,16 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm By(DynamicData.BankId, bankId.get), By(DynamicData.IsPersonalEntity, false) ).nonEmpty - } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, get all the data for specific userId. + } else if(bankId.isEmpty && isPersonalEntity){ //isPersonalEntity == true, check if data exists for specific userId (regardless of how it was created). DynamicData.find( By(DynamicData.DynamicEntityName, dynamicEntityName), NullRef(DynamicData.BankId), - By(DynamicData.IsPersonalEntity, true), By(DynamicData.UserId, userId.getOrElse(null)) ).nonEmpty - } else { + } else { //isPersonalEntity == true, check if data exists for specific userId (regardless of how it was created). DynamicData.find( By(DynamicData.DynamicEntityName, dynamicEntityName), By(DynamicData.BankId, bankId.get), - By(DynamicData.IsPersonalEntity, true), By(DynamicData.UserId, userId.getOrElse(null)) ).nonEmpty } diff --git a/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala b/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala index 53af3e711a..6b31914520 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala @@ -20,6 +20,10 @@ trait ChallengeProvider { basketId: Option[String], // Note: basketId, consentId and transactionRequestId are exclusive here. authenticationMethodId: Option[String], challengeType: String, + // PSD2 Dynamic Linking fields + challengePurpose: Option[String] = None, // Human-readable description shown to user + challengeContextHash: Option[String] = None, // SHA-256 hash of critical transaction fields + challengeContextStructure: Option[String] = None // Comma-separated list of field names in hash ): Box[ChallengeTrait] def getChallenge(challengeId: String): Box[ChallengeTrait] diff --git a/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala b/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala index 654af20e9f..b9fb7e1542 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala @@ -28,8 +28,12 @@ object MappedChallengeProvider extends ChallengeProvider { consentId: Option[String], // Note: consentId and transactionRequestId and basketId are exclusive here. basketId: Option[String], // Note: consentId and transactionRequestId and basketId are exclusive here. authenticationMethodId: Option[String], - challengeType: String, - ): Box[ChallengeTrait] = + challengeType: String, + // PSD2 Dynamic Linking fields + challengePurpose: Option[String] = None, + challengeContextHash: Option[String] = None, + challengeContextStructure: Option[String] = None + ): Box[ChallengeTrait] = tryo ( MappedExpectedChallengeAnswer .create @@ -44,6 +48,10 @@ object MappedChallengeProvider extends ChallengeProvider { .ConsentId(consentId.getOrElse("")) .BasketId(basketId.getOrElse("")) .AuthenticationMethodId(expectedUserId) + // PSD2 Dynamic Linking + .ChallengePurpose(challengePurpose.getOrElse("")) + .ChallengeContextHash(challengeContextHash.getOrElse("")) + .ChallengeContextStructure(challengeContextStructure.getOrElse("")) .saveMe() ) diff --git a/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala b/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala index 38bbf97dbe..0e27f284a5 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/MappedExpectedChallengeAnswer.scala @@ -29,6 +29,11 @@ class MappedExpectedChallengeAnswer extends ChallengeTrait with LongKeyedMapper[ override def defaultValue = 0 } + // PSD2 Dynamic Linking fields + object ChallengePurpose extends MappedString(this, 2000) + object ChallengeContextHash extends MappedString(this, 64) + object ChallengeContextStructure extends MappedString(this, 500) + override def challengeId: String = ChallengeId.get override def challengeType: String = ChallengeType.get override def transactionRequestId: String = TransactionRequestId.get @@ -42,6 +47,11 @@ class MappedExpectedChallengeAnswer extends ChallengeTrait with LongKeyedMapper[ override def scaStatus: Option[SCAStatus] = Option(StrongCustomerAuthenticationStatus.withName(ScaStatus.get)) override def authenticationMethodId: Option[String] = Option(AuthenticationMethodId.get) override def attemptCounter: Int = AttemptCounter.get + + // PSD2 Dynamic Linking + override def challengePurpose: Option[String] = Option(ChallengePurpose.get).filter(_.nonEmpty) + override def challengeContextHash: Option[String] = Option(ChallengeContextHash.get).filter(_.nonEmpty) + override def challengeContextStructure: Option[String] = Option(ChallengeContextStructure.get).filter(_.nonEmpty) } object MappedExpectedChallengeAnswer extends MappedExpectedChallengeAnswer with LongKeyedMetaMapper[MappedExpectedChallengeAnswer] { 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 cdb6cbf6f2..a26a284ddf 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 @@ -1725,20 +1725,20 @@ class DynamicEntityTest extends V400ServerSetup { } { - Then("User1 get my foobar, only return his own records, only one") + Then("User1 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "my" / "FooBar").GET <@ (user1) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { - Then("User2 get my foobar, only return his own records, only one") + Then("User2 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "my" / "FooBar").GET <@ (user2) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { @@ -1851,20 +1851,20 @@ class DynamicEntityTest extends V400ServerSetup { } { - Then("User1 get my foobar, only return his own records, only one") + Then("User1 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "banks" / testBankId1.value / "my" / "FooBar").GET <@ (user1) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { - Then("User2 get my foobar, only return his own records, only one") + Then("User2 get my foobar, returns his own records (including those created via non-/my endpoints)") val requestGetFoobars = (dynamicEntity_Request / "banks" / testBankId1.value / "my" / "FooBar").GET <@ (user2) val responseGetFoobars = makeGetRequest(requestGetFoobars) responseGetFoobars.code should equal(200) - (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(1) + (responseGetFoobars.body \ "foo_bar_list").asInstanceOf[JArray].arr.size should be(2) } { 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 new file mode 100644 index 0000000000..f4e402eca5 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -0,0 +1,551 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2025, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + */ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.ErrorMessages._ +import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import code.entitlement.Entitlement +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.JsonDSL._ +import net.liftweb.json.Serialization.write +import net.liftweb.json._ +import org.scalatest.Tag + +class DynamicEntityTest extends V600ServerSetup { + + override def beforeAll(): Unit = { + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + } + + /** + * Test tags + * Example: To run tests with tag "getPermissions": + * mvn test -D tagsToInclude + * + * This is made possible by the scalatest maven plugin + */ + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.createSystemDynamicEntity)) + object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.updateSystemDynamicEntity)) + object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.getSystemDynamicEntities)) + object ApiEndpoint4 extends Tag(nameOf(Implementations6_0_0.createBankLevelDynamicEntity)) + object ApiEndpoint5 extends Tag(nameOf(Implementations6_0_0.updateBankLevelDynamicEntity)) + object ApiEndpoint6 extends Tag(nameOf(Implementations6_0_0.getBankLevelDynamicEntities)) + object ApiEndpoint7 extends Tag(nameOf(Implementations6_0_0.getMyDynamicEntities)) + object ApiEndpoint8 extends Tag(nameOf(Implementations6_0_0.updateMyDynamicEntity)) + object ApiEndpoint9 extends Tag(nameOf(Implementations6_0_0.getAvailablePersonalDynamicEntities)) + + lazy val bankId = testBankId1.value + + // v6.0.0 request format with snake_case and explicit entity_name + val rightEntityV600 = parse( + """ + |{ + | "entity_name": "foo_bar", + | "has_personal_entity": true, + | "schema": { + | "description": "description of this entity, can be markdown text.", + | "required": [ + | "name" + | ], + | "properties": { + | "name": { + | "type": "string", + | "maxLength": 20, + | "minLength": 3, + | "example": "James Brown", + | "description":"description of **name** field, can be markdown text." + | }, + | "number": { + | "type": "integer", + | "example": 69876172 + | } + | } + | } + |} + |""".stripMargin) + + // Entity with hasPersonalEntity = false + val entityWithoutPersonalV600 = parse( + """ + |{ + | "entity_name": "shared_entity", + | "has_personal_entity": false, + | "schema": { + | "description": "A shared entity without personal endpoints.", + | "required": [ + | "title" + | ], + | "properties": { + | "title": { + | "type": "string", + | "example": "Some Title" + | } + | } + | } + |} + |""".stripMargin) + + // Wrong format - missing required field + val wrongRequiredEntityV600 = parse( + """ + |{ + | "entity_name": "foo_bar", + | "has_personal_entity": true, + | "schema": { + | "description": "description of this entity.", + | "required": [ + | "name_wrong" + | ], + | "properties": { + | "name": { + | "type": "string", + | "example": "James Brown" + | } + | } + | } + |} + |""".stripMargin) + + // Updated entity for PUT tests + val updatedEntityV600 = parse( + """ + |{ + | "entity_name": "foo_bar", + | "has_personal_entity": true, + | "schema": { + | "description": "Updated description of this entity.", + | "required": [ + | "name" + | ], + | "properties": { + | "name": { + | "type": "string", + | "maxLength": 30, + | "minLength": 2, + | "example": "Updated Name", + | "description":"Updated description of **name** field." + | }, + | "number": { + | "type": "integer", + | "example": 12345678 + | } + | } + | } + |} + |""".stripMargin) + + + feature("v6.0.0 System Level Dynamic Entity endpoints with snake_case JSON") { + + scenario("Create System Dynamic Entity - without user credentials", ApiEndpoint1, VersionOfApi) { + When(s"We make a POST request without user credentials") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST + val response = makePostRequest(request, write(rightEntityV600)) + Then("We should get a 401") + response.code should equal(401) + And("error should be " + AuthenticatedUserIsRequired) + response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) + } + + scenario("Create System Dynamic Entity - without proper role", ApiEndpoint1, VersionOfApi) { + When(s"We make a POST request without the role " + CanCreateSystemLevelDynamicEntity) + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(rightEntityV600)) + Then("We should get a 403") + response.code should equal(403) + And("error should contain " + UserHasMissingRoles) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + + scenario("Create and verify v6.0.0 snake_case response format", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + When("We create a dynamic entity with v6.0.0 format") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(rightEntityV600)) + + Then("We should get a 201") + response.code should equal(201) + + val responseJson = response.body + + // Verify snake_case field names exist + And("Response should have snake_case field: dynamic_entity_id") + (responseJson \ "dynamic_entity_id") shouldBe a[JString] + + And("Response should have snake_case field: entity_name") + (responseJson \ "entity_name").extract[String] should equal("foo_bar") + + And("Response should have snake_case field: user_id") + (responseJson \ "user_id").extract[String] should equal(resourceUser1.userId) + + And("Response should have snake_case field: has_personal_entity") + (responseJson \ "has_personal_entity").extract[Boolean] should equal(true) + + And("Response should have schema field with just the schema (no entity name wrapper)") + val schemaField = responseJson \ "schema" + (schemaField \ "description") shouldBe a[JString] + (schemaField \ "required") shouldBe a[JArray] + (schemaField \ "properties") shouldBe a[JObject] + + // Verify schema does NOT contain the entity name as a key (old format would have foo_bar as key) + And("Schema should NOT contain entity name as a dynamic key") + (schemaField \ "foo_bar") should equal(JNothing) + + 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) + + When("We GET system dynamic entities") + val getRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entitiesJson = getResponse.body \ "dynamic_entities" + entitiesJson shouldBe a[JArray] + + val entities = entitiesJson.asInstanceOf[JArray].arr + entities should have size 1 + + val entity = entities.head + And("GET response should also use snake_case fields") + (entity \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) + (entity \ "entity_name").extract[String] should equal("foo_bar") + (entity \ "has_personal_entity").extract[Boolean] should equal(true) + + And("GET response should include record_count field") + (entity \ "record_count") shouldBe a[JInt] + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.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) + + // Create first + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We update the dynamic entity with v6.0.0 format") + val updateRequest = (v6_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).PUT <@(user1) + val updateResponse = makePutRequest(updateRequest, write(updatedEntityV600)) + + Then("We should get a 200") + updateResponse.code should equal(200) + + val responseJson = updateResponse.body + + And("Updated response should use snake_case fields") + (responseJson \ "dynamic_entity_id").extract[String] should equal(dynamicEntityId) + (responseJson \ "entity_name").extract[String] should equal("foo_bar") + + And("Schema should be updated") + val schemaField = responseJson \ "schema" + (schemaField \ "description").extract[String] should equal("Updated description of this entity.") + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.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) + + When("We try to create a dynamic entity with wrong required field") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = makePostRequest(request, write(wrongRequiredEntityV600)) + + Then("We should get a 400") + response.code should equal(400) + + And("Error message should indicate validation failure") + response.body.extract[ErrorMessage].message should include(DynamicEntityInstanceValidateFail) + } + } + + + 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) + 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") + response.code should equal(403) + } + + 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) + + 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) + val response = makePostRequest(request, write(rightEntityV600)) + + Then("We should get a 201") + response.code should equal(201) + + val responseJson = response.body + + And("Response should have snake_case field: bank_id") + (responseJson \ "bank_id").extract[String] should equal(bankId) + + And("Response should have entity_name") + (responseJson \ "entity_name").extract[String] should equal("foo_bar") + + val dynamicEntityId = (responseJson \ "dynamic_entity_id").extract[String] + + // Test GET bank level + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetBankLevelDynamicEntities.toString) + + When("We GET bank level dynamic entities") + val getRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entities = (getResponse.body \ "dynamic_entities").asInstanceOf[JArray].arr + entities should have size 1 + + val entity = entities.head + (entity \ "bank_id").extract[String] should equal(bankId) + (entity \ "entity_name").extract[String] should equal("foo_bar") + (entity \ "record_count") shouldBe a[JInt] + + // Cleanup + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.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) + + // Create first + val createRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We update the bank level dynamic entity") + val updateRequest = (v6_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).PUT <@(user1) + val updateResponse = makePutRequest(updateRequest, write(updatedEntityV600)) + + Then("We should get a 200") + updateResponse.code should equal(200) + + And("Updated response should have snake_case fields") + (updateResponse.body \ "entity_name").extract[String] should equal("foo_bar") + (updateResponse.body \ "bank_id").extract[String] should equal(bankId) + + // Cleanup + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanDeleteBankLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + } + + + feature("v6.0.0 My Dynamic Entities endpoints") { + + scenario("GET My Dynamic Entities - without user credentials", ApiEndpoint7, VersionOfApi) { + When("We make a GET request without user credentials") + val request = (v6_0_0_Request / "my" / "dynamic-entities").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + } + + 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) + + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We GET my dynamic entities") + val getRequest = (v6_0_0_Request / "my" / "dynamic-entities").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entitiesJson = getResponse.body \ "dynamic_entities" + entitiesJson shouldBe a[JArray] + + val entities = entitiesJson.asInstanceOf[JArray].arr + entities.size should be >= 1 + + And("Response should use snake_case fields") + val entity = entities.find(e => (e \ "entity_name").extract[String] == "foo_bar").get + (entity \ "dynamic_entity_id") shouldBe a[JString] + (entity \ "entity_name").extract[String] should equal("foo_bar") + (entity \ "user_id").extract[String] should equal(resourceUser1.userId) + (entity \ "has_personal_entity").extract[Boolean] should equal(true) + + And("Schema field should contain only the schema structure") + val schemaField = entity \ "schema" + (schemaField \ "description") shouldBe a[JString] + (schemaField \ "foo_bar") should equal(JNothing) // Should NOT have entity name as key + + // Test Update My Dynamic Entity + When("We update my dynamic entity") + val updateRequest = (v6_0_0_Request / "my" / "dynamic-entities" / dynamicEntityId).PUT <@(user1) + val updateResponse = makePutRequest(updateRequest, write(updatedEntityV600)) + + Then("We should get a 200") + updateResponse.code should equal(200) + + And("Updated response should use snake_case fields") + (updateResponse.body \ "entity_name").extract[String] should equal("foo_bar") + (updateResponse.body \ "schema" \ "description").extract[String] should equal("Updated description of this entity.") + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + } + + + feature("v6.0.0 Available Personal Dynamic Entities discovery endpoint") { + + scenario("GET Available Personal Dynamic Entities - without user credentials", ApiEndpoint9, VersionOfApi) { + When("We make a GET request without user credentials") + val request = (v6_0_0_Request / "personal-dynamic-entities" / "available").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + } + + scenario("GET Available Personal Dynamic Entities returns only entities with hasPersonalEntity=true", ApiEndpoint9, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + // Create entity WITH hasPersonalEntity = true + val createRequest1 = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response1 = makePostRequest(createRequest1, write(rightEntityV600)) + response1.code should equal(201) + val entityId1 = (response1.body \ "dynamic_entity_id").extract[String] + + // Create entity WITH hasPersonalEntity = false + val createRequest2 = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response2 = makePostRequest(createRequest2, write(entityWithoutPersonalV600)) + response2.code should equal(201) + val entityId2 = (response2.body \ "dynamic_entity_id").extract[String] + + When("We GET available personal dynamic entities") + val getRequest = (v6_0_0_Request / "personal-dynamic-entities" / "available").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + + Then("We should get a 200") + getResponse.code should equal(200) + + val entities = (getResponse.body \ "dynamic_entities").asInstanceOf[JArray].arr + + And("Response should contain only entities with has_personal_entity = true") + val entityNames = entities.map(e => (e \ "entity_name").extract[String]) + entityNames should contain("foo_bar") + entityNames should not contain("shared_entity") + + And("All returned entities should have has_personal_entity = true") + entities.foreach { entity => + (entity \ "has_personal_entity").extract[Boolean] should equal(true) + } + + And("Response should use snake_case fields") + entities.foreach { entity => + (entity \ "dynamic_entity_id") shouldBe a[JString] + (entity \ "entity_name") shouldBe a[JString] + (entity \ "schema") shouldBe a[JObject] + } + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.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) + makeDeleteRequest(deleteRequest2) + } + } + + + 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) + + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(rightEntityV600)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + val schemaField = createResponse.body \ "schema" + + Then("Schema should contain schema fields directly") + (schemaField \ "description") shouldBe a[JString] + (schemaField \ "required") shouldBe a[JArray] + (schemaField \ "properties") shouldBe a[JObject] + + And("Schema should NOT contain the entity name as a nested key (old v4.0.0 format)") + (schemaField \ "foo_bar") should equal(JNothing) + + And("Schema should NOT contain hasPersonalEntity (that's a separate top-level field)") + (schemaField \ "hasPersonalEntity") should equal(JNothing) + (schemaField \ "has_personal_entity") should equal(JNothing) + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.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/MessageDocsJsonSchemaTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala new file mode 100644 index 0000000000..5f6443ab7b --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala @@ -0,0 +1,279 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.api.util.ErrorMessages.InvalidConnector +import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.dwickern.macros.NameOf.nameOf +import com.networknt.schema.{JsonSchemaFactory, SpecVersion} +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json._ +import org.scalatest.Tag + +/** + * Tests for the Message Docs JSON Schema endpoint (v6.0.0) + * + * This endpoint returns message documentation as JSON Schema format for code generation. + * The schema follows JSON Schema draft-07 specification and is validated using the + * networknt/json-schema-validator library (https://github.com/networknt/json-schema-validator). + * + * Schema structure: + * - Root level: $schema, title, description, type, properties, definitions + * - Each message includes: process, description, outbound_schema, inbound_schema + * - Type definitions use $ref references to the definitions section + * - All definitions have: type: "object", properties, required (for non-Option fields) + * + * Industry Standard Compliance: + * - Validated against JSON Schema draft-07 meta-schema + * - Uses standard $ref for type references + * - Compatible with code generation tools like quicktype + */ +class MessageDocsJsonSchemaTest extends V600ServerSetup { + + // Jackson ObjectMapper for converting between Lift JSON and Jackson JsonNode + private val mapper = new ObjectMapper() + + override def beforeAll(): Unit = { + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + } + + /** + * Test tags (for grouping tests) + */ + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.getMessageDocsJsonSchema)) + + feature("Get Message Docs as JSON Schema - v6.0.0") { + + scenario("We get JSON Schema for rabbitmq_vOct2024 connector", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Response should be valid JSON") + val json = response.body.extract[JValue] + json should not be null + + And("Response should have JSON Schema structure") + val schemaVersion = (json \ "$schema").extractOpt[String] + schemaVersion shouldBe defined + schemaVersion.get should include("json-schema.org") + + And("Response should have a title") + val title = (json \ "title").extractOpt[String] + title shouldBe defined + title.get should include("rabbitmq_vOct2024") + + And("Response should have definitions") + val definitions = (json \ "definitions").extractOpt[JObject] + definitions shouldBe defined + + And("Response should have properties with messages array") + val properties = (json \ "properties").extractOpt[JObject] + properties shouldBe defined + + val messagesProperty = (json \ "properties" \ "messages").extractOpt[JObject] + messagesProperty shouldBe defined + + val messagesType = (json \ "properties" \ "messages" \ "type").extractOpt[String] + messagesType shouldBe Some("array") + + And("Each message should have required structure") + val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] + messages should not be empty + + // Check first message has expected fields + val firstMessage = messages.head + (firstMessage \ "process").extractOpt[String] shouldBe defined + (firstMessage \ "description").extractOpt[String] shouldBe defined + (firstMessage \ "message_format").extractOpt[String] shouldBe defined + (firstMessage \ "outbound_schema").extractOpt[JObject] shouldBe defined + (firstMessage \ "inbound_schema").extractOpt[JObject] shouldBe defined + + And("Outbound schema should be valid JSON Schema") + val outboundSchema = (firstMessage \ "outbound_schema").extract[JObject] + // Schema can have either a direct "type" or a "$ref" to definitions for case classes + val outboundType = (outboundSchema \ "type").extractOpt[String] + val outboundRef = (outboundSchema \ "$ref").extractOpt[String] + (outboundType.isDefined || outboundRef.isDefined) shouldBe true + + And("Inbound schema should be valid JSON Schema") + val inboundSchema = (firstMessage \ "inbound_schema").extract[JObject] + // Schema can have either a direct "type" or a "$ref" to definitions for case classes + val inboundType = (inboundSchema \ "type").extractOpt[String] + val inboundRef = (inboundSchema \ "$ref").extractOpt[String] + (inboundType.isDefined || inboundRef.isDefined) shouldBe true + } + + scenario("We get JSON Schema for rest_vMar2019 connector", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rest_vMar2019" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Response should be valid JSON Schema") + val json = response.body.extract[JValue] + val schemaVersion = (json \ "$schema").extractOpt[String] + schemaVersion shouldBe defined + } + + scenario("We get JSON Schema for akka_vDec2018 connector", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "akka_vDec2018" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Response should be valid JSON Schema") + val json = response.body.extract[JValue] + val schemaVersion = (json \ "$schema").extractOpt[String] + schemaVersion shouldBe defined + } + + scenario("We try to get JSON Schema for invalid connector", ApiEndpoint1, VersionOfApi) { + When("We make a request with invalid connector name") + val request = (v6_0_0_Request / "message-docs" / "invalid_connector" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 400 Bad Request response") + response.code should equal(400) + + And("Error message should mention invalid connector") + val errorMessage = (response.body \ "message").extractOpt[String] + errorMessage shouldBe defined + errorMessage.get should include("Invalid Connector") + } + + scenario("We verify schema includes nested type definitions", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Definitions should include common types") + val json = response.body.extract[JValue] + val definitions = (json \ "definitions").extract[JObject] + + // Should have definitions for common adapter types + val definitionNames = definitions.obj.map(_.name) + definitionNames should not be empty + + // Common types that should be present in RabbitMQ schemas + // (exact names depend on the case classes, but there should be several) + definitionNames.length should be > 5 + + And("Each definition should have proper schema structure") + definitions.obj.foreach { case JField(name, schema) => + val schemaObj = schema.asInstanceOf[JObject] + val schemaType = (schemaObj \ "type").extractOpt[String] + schemaType shouldBe Some("object") + + val properties = (schemaObj \ "properties").extractOpt[JObject] + properties shouldBe defined + } + } + + scenario("We verify schema marks required fields correctly", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Schemas should indicate required fields") + val json = response.body.extract[JValue] + val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] + + messages.foreach { message => + val outboundSchema = (message \ "outbound_schema").extract[JObject] + val inboundSchema = (message \ "inbound_schema").extract[JObject] + + // Check if required fields are present (they may or may not be required depending on the case class) + val outboundRequired = (outboundSchema \ "required").extractOpt[List[String]] + val inboundRequired = (inboundSchema \ "required").extractOpt[List[String]] + + // At minimum, the structure should be present + outboundSchema should not be null + inboundSchema should not be null + } + } + + scenario("We verify process names match connector method names", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Process names should follow obp.methodName pattern") + val json = response.body.extract[JValue] + val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]] + + messages.foreach { message => + val process = (message \ "process").extract[String] + process should startWith("obp.") + process.length should be > 4 + } + } + + scenario("We validate schema is industry-standard JSON Schema draft-07 using networknt validator", ApiEndpoint1, VersionOfApi) { + When("We make a request to get message docs as JSON Schema") + val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET + val response = makeGetRequest(request) + + Then("We should get a 200 OK response") + response.code should equal(200) + + And("Schema should be valid according to JSON Schema draft-07 specification") + val schemaString = compactRender(response.body) + val schemaNode = mapper.readTree(schemaString) + + // Use networknt JSON Schema validator with draft-07 + val factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) + val jsonSchema = factory.getSchema(schemaNode) + + // The schema should load without errors (this validates the schema structure) + jsonSchema should not be null + + And("Schema should have valid definitions that can be resolved") + val definitions = (response.body \ "definitions").extract[JObject] + definitions.obj.length should be > 100 // Should have many type definitions + + And("Each definition should be valid JSON Schema") + definitions.obj.foreach { case JField(name, defn) => + val defnString = compactRender(defn) + val defnNode = mapper.readTree(defnString) + // Create a schema from each definition to validate it + val defnSchema = factory.getSchema(defnNode) + defnSchema should not be null + } + + And("$ref references should resolve correctly within the schema") + val messages = (response.body \ "properties" \ "messages" \ "items").extract[List[JValue]] + val firstMessage = messages.head + val outboundRef = (firstMessage \ "outbound_schema" \ "$ref").extractOpt[String] + outboundRef shouldBe defined + outboundRef.get should startWith("#/definitions/") + + // Extract the referenced definition name and verify it exists + val refName = outboundRef.get.replace("#/definitions/", "") + val definitionNames = definitions.obj.map(_.name) + definitionNames should contain(refName) + } + } +} \ No newline at end of file diff --git a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data index ffdf640c9c..4d255759f9 100644 Binary files a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data and b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data differ diff --git a/obp-api/src/test/scala/code/setup/ServerSetup.scala b/obp-api/src/test/scala/code/setup/ServerSetup.scala index f6acfc8c1a..7993c0db1c 100644 --- a/obp-api/src/test/scala/code/setup/ServerSetup.scala +++ b/obp-api/src/test/scala/code/setup/ServerSetup.scala @@ -28,14 +28,18 @@ TESOBE (http://www.tesobe.com/) package code.setup import _root_.net.liftweb.json.JsonAST.JObject +import bootstrap.liftweb.ToSchemify import code.TestServer import code.api.util.APIUtil._ import code.api.util.{APIUtil, CustomJsonFormats} +import code.model.{Consumer, Nonce, Token} +import code.model.dataAccess.{AuthUser, ResourceUser} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model.{AccountId, BankId} import dispatch._ import net.liftweb.common.{Empty, Full} import net.liftweb.json.JsonDSL._ +import net.liftweb.mapper.MetaMapper import org.scalatest._ trait ServerSetup extends FeatureSpec with SendServerRequests @@ -64,7 +68,42 @@ trait ServerSetup extends FeatureSpec with SendServerRequests // This prevents conflicts when both RunWebApp and tests are running System.setProperty("pekko.remote.artery.canonical.port", "0") System.setProperty("pekko.remote.artery.bind.port", "0") - + + /** + * Reset database before each test class to ensure test isolation. + * + * This prevents test pollution where state from one test class leaks into another. + * All tests share a single TestServer/database instance, so we need to clean up + * before each test class starts. + * + * We preserve only the essential OAuth/auth tables (Nonce, Token, Consumer, AuthUser, ResourceUser) + * as these are needed for test authentication and are managed by DefaultUsers trait. + */ + override def beforeAll(): Unit = { + super.beforeAll() + resetDatabaseForTestClass() + } + + /** + * Resets database tables to ensure clean state for each test class. + * Preserves auth-related tables that are managed separately by DefaultUsers. + */ + protected def resetDatabaseForTestClass(): Unit = { + def exclusion(m: MetaMapper[_]): Boolean = { + m == Nonce || m == Token || m == Consumer || m == AuthUser || m == ResourceUser + } + + logger.info(s"[TEST ISOLATION] Resetting database before test class: ${this.getClass.getSimpleName}") + ToSchemify.models.filterNot(exclusion).foreach { model => + try { + model.bulkDelete_!!() + } catch { + case e: Exception => + logger.warn(s"[TEST ISOLATION] Failed to clear table for ${model.getClass.getSimpleName}: ${e.getMessage}") + } + } + } + val server = TestServer def baseRequest = host(server.host, server.port) val secured = APIUtil.getPropsAsBoolValue("external.https", false) diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala index c81aac363d..f048d56c78 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala @@ -581,14 +581,17 @@ case class ChallengeCommons( override val expectedUserId : String , override val salt: String , override val successful: Boolean, - + override val challengeType: String, override val consentId: Option[String], override val basketId: Option[String] = None, override val scaMethod: Option[SCA], override val scaStatus: Option[SCAStatus], override val authenticationMethodId: Option[String] , - override val attemptCounter: Int = 0 //NOTE: set the default value here, so do not break current connectors + override val attemptCounter: Int = 0, //NOTE: set the default value here, so do not break current connectors + override val challengePurpose: Option[String] = None, + override val challengeContextHash: Option[String] = None, + override val challengeContextStructure: Option[String] = None ) extends ChallengeTrait object ChallengeCommons extends Converter[ChallengeTrait, ChallengeCommons] @@ -673,7 +676,10 @@ case class ChallengeTraitCommons( scaMethod: Option[SCA], scaStatus: Option[SCAStatus], authenticationMethodId: Option[String], - attemptCounter: Int) extends ChallengeTrait with JsonFieldReName + attemptCounter: Int, + challengePurpose: Option[String] = None, + challengeContextHash: Option[String] = None, + challengeContextStructure: Option[String] = None) extends ChallengeTrait with JsonFieldReName object ChallengeTraitCommons extends Converter[ChallengeTrait, ChallengeTraitCommons] diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala index c89720376a..1b7a69b882 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala @@ -647,8 +647,16 @@ trait ChallengeTrait { def scaMethod: Option[SCA] def scaStatus: Option[SCAStatus] def authenticationMethodId: Option[String] - + def attemptCounter: Int + + // PSD2 Dynamic Linking support - these fields ensure the authentication is linked to the transaction details + // challenge_purpose: Human-readable description of what is being authorized (shown to user in SMS/email) + def challengePurpose: Option[String] + // challenge_context_hash: SHA-256 hash of critical transaction fields for tamper detection + def challengeContextHash: Option[String] + // challenge_context_structure: Comma-separated list of field names included in the hash + def challengeContextStructure: Option[String] }