diff --git a/brand-assets/README.md b/brand-assets/README.md
new file mode 100644
index 0000000000..f28872a67b
--- /dev/null
+++ b/brand-assets/README.md
@@ -0,0 +1,31 @@
+# Open Bank Project Brand Assets
+
+Official Open Bank Project logos, kept in this repository so that documentation,
+diagrams and glossary items can reference them from a stable, version-controlled
+location instead of hotlinking the website.
+
+Source: https://www.openbankproject.com/brand-assets/ (downloaded 2026-08-25).
+These assets are designated for **press and official partner use only**.
+
+## Files
+
+| File | Size | Use |
+|---|---|---|
+| `horizontal/obp-logo-horizontal.svg` | vector | Preferred where SVG is supported |
+| `horizontal/obp-logo-horizontal-colour.png` | 1000×127 | Full logo, light backgrounds |
+| `horizontal/obp-logo-horizontal-white.png` | 1000×127 | Full logo, dark backgrounds |
+| `horizontal/obp-logo-horizontal-green-white.png` | 1000×127 | Green/white variant |
+| `horizontal/obp-logo-horizontal-colour-small.png` | 206×28 | Small inline use |
+| `vertical/obp-logo-vertical-*.png` | 421×202 / 154×78 | Stacked logo variants |
+| `icon/obp-logo-icon-colour.png` | 79×78 | Icon only, light backgrounds |
+| `icon/obp-logo-icon-dark.png` | 79×78 | Icon only, dark variant |
+| `icon/obp-logo-icon-white.png` | 79×78 | Icon only, dark backgrounds |
+
+## Hotlinking
+
+Once merged to the `develop` branch of `OpenBankProject/OBP-API`, files can be
+referenced from markdown and diagrams via, e.g.:
+
+```
+https://raw.githubusercontent.com/OpenBankProject/OBP-API/develop/brand-assets/horizontal/obp-logo-horizontal-colour.png
+```
diff --git a/brand-assets/horizontal/obp-logo-horizontal-colour-small.png b/brand-assets/horizontal/obp-logo-horizontal-colour-small.png
new file mode 100644
index 0000000000..a0b75b39fc
Binary files /dev/null and b/brand-assets/horizontal/obp-logo-horizontal-colour-small.png differ
diff --git a/brand-assets/horizontal/obp-logo-horizontal-colour.png b/brand-assets/horizontal/obp-logo-horizontal-colour.png
new file mode 100644
index 0000000000..28b9143d16
Binary files /dev/null and b/brand-assets/horizontal/obp-logo-horizontal-colour.png differ
diff --git a/brand-assets/horizontal/obp-logo-horizontal-green-white.png b/brand-assets/horizontal/obp-logo-horizontal-green-white.png
new file mode 100644
index 0000000000..4b0b904632
Binary files /dev/null and b/brand-assets/horizontal/obp-logo-horizontal-green-white.png differ
diff --git a/brand-assets/horizontal/obp-logo-horizontal-white.png b/brand-assets/horizontal/obp-logo-horizontal-white.png
new file mode 100644
index 0000000000..c19e8a7d15
Binary files /dev/null and b/brand-assets/horizontal/obp-logo-horizontal-white.png differ
diff --git a/brand-assets/horizontal/obp-logo-horizontal.svg b/brand-assets/horizontal/obp-logo-horizontal.svg
new file mode 100644
index 0000000000..966461ff07
--- /dev/null
+++ b/brand-assets/horizontal/obp-logo-horizontal.svg
@@ -0,0 +1,80 @@
+
diff --git a/brand-assets/icon/obp-logo-icon-colour.png b/brand-assets/icon/obp-logo-icon-colour.png
new file mode 100644
index 0000000000..a71b944947
Binary files /dev/null and b/brand-assets/icon/obp-logo-icon-colour.png differ
diff --git a/brand-assets/icon/obp-logo-icon-dark.png b/brand-assets/icon/obp-logo-icon-dark.png
new file mode 100644
index 0000000000..9999b49d0d
Binary files /dev/null and b/brand-assets/icon/obp-logo-icon-dark.png differ
diff --git a/brand-assets/icon/obp-logo-icon-white.png b/brand-assets/icon/obp-logo-icon-white.png
new file mode 100644
index 0000000000..6af838f0a7
Binary files /dev/null and b/brand-assets/icon/obp-logo-icon-white.png differ
diff --git a/brand-assets/vertical/obp-logo-vertical-all-white.png b/brand-assets/vertical/obp-logo-vertical-all-white.png
new file mode 100644
index 0000000000..491d2d46cc
Binary files /dev/null and b/brand-assets/vertical/obp-logo-vertical-all-white.png differ
diff --git a/brand-assets/vertical/obp-logo-vertical-colour-small.png b/brand-assets/vertical/obp-logo-vertical-colour-small.png
new file mode 100644
index 0000000000..4ac4129f87
Binary files /dev/null and b/brand-assets/vertical/obp-logo-vertical-colour-small.png differ
diff --git a/brand-assets/vertical/obp-logo-vertical-dark-small.png b/brand-assets/vertical/obp-logo-vertical-dark-small.png
new file mode 100644
index 0000000000..17b47870a9
Binary files /dev/null and b/brand-assets/vertical/obp-logo-vertical-dark-small.png differ
diff --git a/brand-assets/vertical/obp-logo-vertical-white-black.png b/brand-assets/vertical/obp-logo-vertical-white-black.png
new file mode 100644
index 0000000000..d00dd3504d
Binary files /dev/null and b/brand-assets/vertical/obp-logo-vertical-white-black.png differ
diff --git a/brand-assets/vertical/obp-logo-vertical-white-small.png b/brand-assets/vertical/obp-logo-vertical-white-small.png
new file mode 100644
index 0000000000..3e5c7a0122
Binary files /dev/null and b/brand-assets/vertical/obp-logo-vertical-white-small.png differ
diff --git a/brand-assets/vertical/obp-logo-vertical-white.png b/brand-assets/vertical/obp-logo-vertical-white.png
new file mode 100644
index 0000000000..356e435556
Binary files /dev/null and b/brand-assets/vertical/obp-logo-vertical-white.png differ
diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala
index 63071be975..b64b085c94 100644
--- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala
+++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala
@@ -22,7 +22,6 @@ object DynamicEndpointCodeGenerator {
| // if the requestUrl of resourceDoc is /hello/banks/BANK_ID/world
| // the request path is /hello/banks/bank_x/world
| //pathParams.get("BANK_ID") will get Option("bank_x") value
- | val pathParams = getPathParams(callContext, request)
| $variables
|""".stripMargin
} else ""
@@ -30,17 +29,17 @@ object DynamicEndpointCodeGenerator {
val (requestBodyCaseClasses, responseBodyCaseClasses) = buildCaseClasses(fragment.exampleRequestBody, fragment.successResponseBody)
def requestEntityExp(str:String) =
- s""" val requestEntity = request.json match {
- | case Full(zson) =>
- | try {
- | zson.extract[$str]
- | } catch {
- | case e: MappingException =>
- | return Full(errorJsonResponse(s"$$InvalidJsonFormat $${e.msg}"))
- | }
- | case _: EmptyBox =>
- | return Full(errorJsonResponse(s"$$InvalidRequestPayload Current request has no payload"))
- | }
+ s""" val requestEntity = callContext.httpBody.filter(_.nonEmpty) match {
+ | case Some(rawBody) =>
+ | try {
+ | com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[$str]
+ | } catch {
+ | case e: MappingException =>
+ | return errorResponse(s"$$InvalidJsonFormat $${e.msg}")
+ | }
+ | case None =>
+ | return errorResponse(s"$$InvalidRequestPayload Current request has no payload")
+ | }
|""".stripMargin
val requestEntity = fragment.exampleRequestBody match {
@@ -71,11 +70,12 @@ object DynamicEndpointCodeGenerator {
| val requestUrl = "${fragment.requestUrl}"
|
| // copy the whole method body as "dynamicResourceDoc" method body
- | override protected def process(callContext: CallContext, request: Req): Box[JsonResponse] = {
+ | override protected def process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]): IO[Response[IO]] = {
| // please add import sentences here, those used by this method
|
| val Some(resourceDoc) = callContext.resourceDocument
- | val hasRequestBody = request.body.isDefined
+ | // the request body is available as a String on the CallContext (read by Http4sCallContextBuilder)
+ | val hasRequestBody = callContext.httpBody.exists(_.nonEmpty)
|
|$pathVariables
|
diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala
index a625ce703e..744fe950b4 100644
--- a/obp-api/src/main/scala/code/api/util/APIUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala
@@ -3254,6 +3254,18 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
base64EncodedSha256(in)
}
+ /**
+ * Lower-case hex SHA-256 of the given string (UTF-8). Used to fingerprint the source of
+ * runtime-compiled dynamic code (e.g. a Dynamic Resource Doc's method body) so that a stored
+ * record carries an integrity hash: it lets an operator answer "has this code changed since it
+ * was created?" without diffing the raw body, and is the value a future code-signing / approval
+ * step signs over.
+ */
+ def sha256Hex(in: String): String = {
+ val digest = java.security.MessageDigest.getInstance("SHA-256").digest(in.getBytes("UTF-8"))
+ digest.map(b => f"$b%02x").mkString
+ }
+
/**
* Create the explicit CounterpartyId, (Used in `Create counterparty for an account` endpoint ).
* This is just a UUID, use both in Counterparty.counterpartyId and CounterpartyMetadata.counterpartyId
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 b3a0792f76..6e0cc03269 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -3589,6 +3589,89 @@ object Glossary extends MdcLoggable {
| * [Introduction to Dynamic Endpoints](https://vimeo.com/426235612)
| * [Features of Dynamic Endpoints](https://vimeo.com/444133309)
|
+""".stripMargin)
+
+ glossaryItems += GlossaryItem(
+ title = "Dynamic Resource Doc",
+ description =
+ s"""
+|A Dynamic Resource Doc defines a *single* Endpoint at runtime: its verb, URL path, summary, description, example request and response bodies, error list, tags and Roles - plus a *method body* written in Scala which is compiled at runtime and becomes the handler of the Endpoint.
+|
+|Whereas a Dynamic Endpoint (see ${getGlossaryItemLink("Dynamic Endpoint Manage")}) is created from a Swagger / OpenAPI file and contains *no code* (its behaviour is selected by the swagger `host` field), a Dynamic Resource Doc *is* code: the method body has access to the full CallContext and can transform payloads, call Connector methods and NewStyle functions, or invoke Dynamic Message Docs.
+|
+|Like all Resource Docs, Dynamic Resource Docs are part of the server registry of the API (see ${getGlossaryItemLink("Resource Doc")}), so they appear in the API Explorer and resource-docs endpoints like any Static endpoint.
+|
+|Dynamic Resource Docs can be created at System level or Bank / Space level, and are served under the `/obp/dynamic-endpoint/dynamic-resource-doc` path prefix (configurable via the `url.prefix.dynamic.resourceDoc` prop).
+|
+|Authentication and Role checks are applied to the compiled endpoint exactly as for Static endpoints - including the checks that run inside the shared authentication step: Consumer disabled, User locked / deleted, Consent processing and Rate Limiting.
+|
+|Some cross-cutting features of the Static pipeline do *not* currently apply to runtime-compiled Dynamic Resource Doc endpoints: API Metrics are not recorded, the JSON Schema Validation and Force-Error interceptors are not run, the Idempotency-Key mechanism is unavailable, and handlers run on auto-commit (no request-scoped database transaction). Dynamic Endpoints created from Swagger (the proxy path) *do* record Metrics and *do* run the JSON Schema Validation interceptors.
+|
+|Because the method body is user-supplied code compiled at runtime, this feature is guarded by the `allow_user_generated_scala_code` prop (default: false) and the Roles CanCreateDynamicResourceDoc / CanCreateBankLevelDynamicResourceDoc etc.
+|
+|A helper endpoint (`POST /management/dynamic-resource-docs/endpoint-code`) can generate a method-body template from example request / response bodies.
+|
+|See ${getGlossaryItemLink("Dynamic Code Paths")} for how Dynamic Resource Docs relate to the other runtime-defined building blocks.
+|
+""".stripMargin)
+
+ glossaryItems += GlossaryItem(
+ title = "Dynamic Code Paths",
+ description =
+ s"""
+|OBP offers several building blocks for defining API behaviour at *runtime* - stored in the OBP database as instance configuration rather than compiled into the source code. This item explains how they fit together.
+|
+|**The building blocks**
+|
+|At the *API surface* layer (what URL / verb exists, who may call it):
+|
+|1) **Dynamic Endpoint** (${getGlossaryItemLink("Dynamic Endpoint Manage")}) - created from a Swagger / OpenAPI file. No code. Every operation in the file becomes a live endpoint with an auto-generated Role.
+|
+|2) **Dynamic Resource Doc** (${getGlossaryItemLink("Dynamic Resource Doc")}) - one endpoint definition *plus* a Scala method body compiled at runtime. The code is the handler.
+|
+|At the *Connector* layer (how a backend system is reached):
+|
+|3) **Method Routing** (${getGlossaryItemLink("Method Routing")}) - a routing rule that selects which Connector implementation serves a given Connector method (per bank, per URL pattern etc.). Pure configuration, no code.
+|
+|4) **Connector Method** (${getGlossaryItemLink("Connector Method")}) - a runtime-compiled body (Scala, Java or JavaScript) for one of the *existing* methods of the Connector trait (e.g. getBanks, makePaymentv210, dynamicEndpointProcess). Executed when a Method Routing rule routes that method to `connector = internal`.
+|
+|5) **Dynamic Message Doc** (${getGlossaryItemLink("Dynamic Message Doc")}) - a runtime-compiled function keyed by a *process name*, for logic that does not correspond to an existing Connector method. Invoked from other dynamic code (or by Dynamic Entity storage operations).
+|
+|Related: **Dynamic Entities** (${getGlossaryItemLink("Dynamic-Entities")}) provide runtime-defined data storage, and **Endpoint Mapping** (${getGlossaryItemLink("Endpoint Mapping")}) maps Dynamic Endpoint JSON fields onto Dynamic Entity fields.
+|
+|**How they compose - the paths**
+|
+|```
+| +--> host=obp_mock ......... returns swagger example (mock)
+| |
+| Dynamic Endpoint (swagger) --+--> host=dynamic_entity ... Endpoint Mapping
+| no code | -> Dynamic Entity storage (data-backed)
+| |
+| +--> any other host ......... Method Routing:
+| connector=rest -> HTTP proxy to backend
+| connector=internal -> Connector Method (code)
+|
+| Dynamic Resource Doc ------------> compiled Scala handler
+| code at the endpoint layer |-> Connector methods (routed by Method Routing)
+| |-> Dynamic Message Docs (by process name)
+| |-> any transformation / orchestration logic
+|```
+|
+|**Choosing a path**
+|
+|* Need a quick mock of an API from its spec? Dynamic Endpoint with `host = obp_mock`.
+|* Need a data-backed CRUD API with no code? Dynamic Endpoint with `host = dynamic_entity` + Endpoint Mapping + a Dynamic Entity.
+|* Need to pass requests through to an existing backend *unchanged*? Dynamic Endpoint + Method Routing with a `url` parameter (transparent HTTP proxy - no payload transformation, no credential minting).
+|* Need transformation, authentication against the backend, error mapping or orchestration? Use code: either a Dynamic Resource Doc (code at the endpoint layer - one self-contained artifact per endpoint) or a Connector Method (code at the connector seam - keeps backend integration reusable across endpoints and swappable via Method Routing). These combine well: Dynamic Resource Docs for the API surface, Connector Methods / Dynamic Message Docs for the backend calls.
+|
+|**Static vs Dynamic**
+|
+|Static endpoints (${getGlossaryItemLink("Static Endpoint")}) are Scala source code in Git, changed via release and restart. All the dynamic building blocks above live in the OBP database of the instance: they can be created and changed in real time over the management API (or via the API Manager UI) with *no code deployment and no restart*, and they never require instance-specific code in the public source repositories.
+|
+|**Guards**
+|
+|Runtime-compiled code (Dynamic Resource Docs, Connector Methods, Dynamic Message Docs) is disabled unless the `allow_user_generated_scala_code` prop is set to true, and every creation endpoint requires its corresponding Role. Dynamic Endpoints (swagger, no code) are not affected by that prop; each generated endpoint is protected by its own auto-generated Role.
+|
""".stripMargin)
glossaryItems += GlossaryItem(
@@ -3929,8 +4012,6 @@ object Glossary extends MdcLoggable {
|
|You can also use these endpoints to create your own helper methods in OBP code.
|
- | This feature is somewhat work in progress (WIP).
-|
|The following videos are available:
|* [Introduction to Dynamic Message Doc] (https://vimeo.com/623317747)
|
@@ -5894,6 +5975,14 @@ object Glossary extends MdcLoggable {
|└──────────────────┘ └────────────────────────┘ └──────────────┘
|```
|
+ |## Architecture diagram
+ |
+ |The full picture — Portal/API Explorer, Opey, external MCP clients (Claude Code, Claude Desktop, IDE agents), OBP-OIDC, the numbered consent flow, and OBP-API down to the core banking systems:
+ |
+ |
+ |
+ |The editable master is a Lucidchart document linked from the [OBP-MCP README](https://github.com/OpenBankProject/OBP-MCP#architecture).
+ |
|## Three-step discovery + call (no RAG, no vector DB)
|
|OBP-MCP avoids embedding the 4 MB OpenAPI spec into the LLM's context. Instead it exposes three tools that work together:
@@ -5954,6 +6043,10 @@ object Glossary extends MdcLoggable {
|
|Since [OBP-MCP](/glossary#OBP-MCP) was introduced, Opey has been refactored from a self-contained chatbot (with its own endpoint search, glossary search, and OBP HTTP client baked in) into a focused **agent** that *consumes* OBP-MCP as its primary tool source.
|
+ |
+ |
+ |Besides the MCP path shown above, Opey makes some direct HTTP calls to OBP-API for its own infrastructure (session validation via `/users/current`, admin DirectLogin operations, persisting LangGraph checkpoints as dynamic entities, and health probes) — see the architecture section of the [Opey README](https://github.com/OpenBankProject/OBP-Opey-II#architecture-how-opey-reaches-the-obp-api) for the detail diagram.
+ |
|Opey's `mcp_servers.json` typically points at a running OBP-MCP instance:
|
|```json
diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala
index 62809729ac..8145f828b9 100644
--- a/obp-api/src/main/scala/code/api/util/NewStyle.scala
+++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala
@@ -4272,14 +4272,17 @@ object NewStyle extends MdcLoggable{
def createJsonConnectorMethod(connectorMethod: JsonConnectorMethod, callContext: Option[CallContext]): OBPReturnType[JsonConnectorMethod] =
Future {
- val newInternalConnector = ConnectorMethodProvider.provider.vend.create(connectorMethod)
+ // provenance is taken from the authenticated CallContext user, never from the request body
+ val createdByUserId = callContext.flatMap(_.user).map(_.userId)
+ val newInternalConnector = ConnectorMethodProvider.provider.vend.create(connectorMethod, createdByUserId)
val errorMsg = s"$UnknownError Can not create Connector Method in the backend. "
(unboxFullOrFail(newInternalConnector, callContext, errorMsg, 400), callContext)
}
def updateJsonConnectorMethod(connectorMethodId: String, connectorMethodBody: String, programmingLang: String, callContext: Option[CallContext]): OBPReturnType[JsonConnectorMethod] =
Future {
- val updatedConnectorMethod = ConnectorMethodProvider.provider.vend.update(connectorMethodId, connectorMethodBody, programmingLang)
+ val updatedByUserId = callContext.flatMap(_.user).map(_.userId)
+ val updatedConnectorMethod = ConnectorMethodProvider.provider.vend.update(connectorMethodId, connectorMethodBody, programmingLang, updatedByUserId)
val errorMsg = s"$UnknownError Can not update Connector Method in the backend. "
(unboxFullOrFail(updatedConnectorMethod, callContext, errorMsg, 400), callContext)
}
@@ -4316,14 +4319,17 @@ object NewStyle extends MdcLoggable{
def createJsonDynamicResourceDoc(bankId: Option[String], dynamicResourceDoc: JsonDynamicResourceDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicResourceDoc] =
Future {
- val newInternalConnector = DynamicResourceDocProvider.provider.vend.create(bankId, dynamicResourceDoc)
+ // provenance is taken from the authenticated CallContext user, never from the request body
+ val createdByUserId = callContext.flatMap(_.user).map(_.userId)
+ val newInternalConnector = DynamicResourceDocProvider.provider.vend.create(bankId, dynamicResourceDoc, createdByUserId)
val errorMsg = s"$UnknownError Can not create Dynamic Resource Doc in the backend. "
(unboxFullOrFail(newInternalConnector, callContext, errorMsg, 400), callContext)
}
def updateJsonDynamicResourceDoc(bankId: Option[String], entity: JsonDynamicResourceDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicResourceDoc] =
Future {
- val updatedConnectorMethod = DynamicResourceDocProvider.provider.vend.update(bankId, entity: JsonDynamicResourceDoc)
+ val updatedByUserId = callContext.flatMap(_.user).map(_.userId)
+ val updatedConnectorMethod = DynamicResourceDocProvider.provider.vend.update(bankId, entity, updatedByUserId)
val errorMsg = s"$UnknownError Can not update Dynamic Resource Doc in the backend. "
(unboxFullOrFail(updatedConnectorMethod, callContext, errorMsg, 400), callContext)
}
@@ -4354,14 +4360,17 @@ object NewStyle extends MdcLoggable{
def createJsonDynamicMessageDoc(bankId: Option[String], dynamicMessageDoc: JsonDynamicMessageDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicMessageDoc] =
Future {
- val newInternalConnector = DynamicMessageDocProvider.provider.vend.create(bankId, dynamicMessageDoc)
+ // provenance is taken from the authenticated CallContext user, never from the request body
+ val createdByUserId = callContext.flatMap(_.user).map(_.userId)
+ val newInternalConnector = DynamicMessageDocProvider.provider.vend.create(bankId, dynamicMessageDoc, createdByUserId)
val errorMsg = s"$UnknownError Can not create Dynamic Message Doc in the backend. "
(unboxFullOrFail(newInternalConnector, callContext, errorMsg, 400), callContext)
}
def updateJsonDynamicMessageDoc(bankId: Option[String], entity: JsonDynamicMessageDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicMessageDoc] =
Future {
- val updatedConnectorMethod = DynamicMessageDocProvider.provider.vend.update(bankId: Option[String], entity: JsonDynamicMessageDoc)
+ val updatedByUserId = callContext.flatMap(_.user).map(_.userId)
+ val updatedConnectorMethod = DynamicMessageDocProvider.provider.vend.update(bankId, entity, updatedByUserId)
val errorMsg = s"$UnknownError Can not update Dynamic Message Doc in the backend. "
(unboxFullOrFail(updatedConnectorMethod, callContext, errorMsg, 400), callContext)
}
diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
index 6851508e34..328fbd7a62 100644
--- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
+++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
@@ -7599,6 +7599,9 @@ object Http4s400 {
http4sPartialFunction = Some(deleteTransactionRequestAttributeDefinition)
)
+ // Intentional drift from the Lift baseline: description expanded to document the
+ // scramble (soft delete) behaviour, and UserNotFoundById added to the error list
+ // (the handler returns 404 via NewStyle.function.findByUserId).
staticResourceDocs += ResourceDoc(
implementedInApiVersion,
nameOf(deleteUser),
@@ -7607,13 +7610,25 @@ object Http4s400 {
"Delete a User",
s"""Delete a User.
|
+ |This is a soft delete: the database row is kept, but the User's personal data is scrambled i.e. overwritten with random values:
+ |
+ |* The username is replaced with DELETED-
+ |* The first name, last name and email are replaced with random values
+ |* The password is replaced with a random value and the user is invalidated, so the User can no longer log in
+ |* Any User Invitation that created the User is scrambled in the same way
+ |
+ |The User is marked as deleted; any subsequent authentication as this User (including via existing tokens or consents) is rejected.
+ |
+ |The USER_ID is retained, so records that reference it (e.g. metrics and transaction history) keep their audit value but can no longer be linked to a person.
+ |
+ |This action cannot be undone.
|
|${userAuthenticationMessage(true)}
|
|""",
EmptyBody,
EmptyBody,
- List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ List($AuthenticatedUserIsRequired, UserNotFoundById, UserHasMissingRoles, UnknownError),
List(apiTagUser),
Some(List(canDeleteUser)),
http4sPartialFunction = Some(deleteUser)
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
index cbcbe948bb..889441ddd2 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
@@ -4691,6 +4691,190 @@ object Http4s700 {
)
}
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Dynamic-code provenance (v7.0.0, read-only)
+ // ─────────────────────────────────────────────────────────────────────────
+ // GET-only endpoints that expose the provenance captured on the v4.0.0 create/update
+ // endpoints (created_by_user_id, updated_by_user_id, method_body_hash, created_at,
+ // updated_at) for the three runtime-compiled-code types. The v4 create/update/get shapes are
+ // frozen (STABLE); these v7 reads wrap the unchanged v4 resource JSON with a `provenance`
+ // object. Create/update/delete stay on v4. Roles mirror the v4 GET roles.
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ val getDynamicResourceDocsProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "dynamic-resource-docs" =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(code.dynamicResourceDoc.DynamicResourceDoc.findAll())
+ .map(rows => JSONFactory700.DynamicResourceDocsProvenanceJsonV700(
+ rows.map(JSONFactory700.createDynamicResourceDocProvenanceJsonV700)))
+ }
+ }
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getDynamicResourceDocsProvenance),
+ "GET",
+ "/management/dynamic-resource-docs",
+ "Get Dynamic Resource Docs (with provenance)",
+ s"""Returns all Dynamic Resource Docs, each wrapped with a `provenance` object recording who created / last updated the runtime-compiled code and a SHA-256 of its method body.
+ |
+ |This is the v7.0.0 read view of the v4.0.0 Dynamic Resource Docs; create / update / delete remain on v4.0.0.
+ |
+ |${userAuthenticationMessage(true)}""".stripMargin,
+ EmptyBody,
+ JSONFactory700.DynamicResourceDocsProvenanceJsonV700(Nil),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ apiTagDynamicResourceDoc :: Nil,
+ Some(List(ApiRole.canGetAllDynamicResourceDocs)),
+ http4sPartialFunction = Some(getDynamicResourceDocsProvenance)
+ )
+
+ val getDynamicResourceDocProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "dynamic-resource-docs" / dynamicResourceDocId =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(code.dynamicResourceDoc.DynamicResourceDoc.find(
+ By(code.dynamicResourceDoc.DynamicResourceDoc.DynamicResourceDocId, dynamicResourceDocId)))
+ .map(box => unboxFullOrFail(box, Some(cc), s"$DynamicResourceDocNotFound Current DYNAMIC_RESOURCE_DOC_ID($dynamicResourceDocId)", 404))
+ .map(JSONFactory700.createDynamicResourceDocProvenanceJsonV700)
+ }
+ }
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getDynamicResourceDocProvenance),
+ "GET",
+ "/management/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID",
+ "Get Dynamic Resource Doc (with provenance)",
+ s"""Returns the Dynamic Resource Doc specified by DYNAMIC_RESOURCE_DOC_ID, wrapped with a `provenance` object (created_by_user_id, updated_by_user_id, method_body_hash, created_at, updated_at).
+ |
+ |This is the v7.0.0 read view of the v4.0.0 Dynamic Resource Doc; create / update / delete remain on v4.0.0.
+ |
+ |${userAuthenticationMessage(true)}""".stripMargin,
+ EmptyBody,
+ JSONFactory700.DynamicResourceDocProvenanceJsonV700(
+ jsonDynamicResourceDoc,
+ JSONFactory700.ProvenanceJsonV700(Some(code.api.util.ExampleValue.userIdExample.value), None, Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), Some(APIUtil.DateWithMsExampleString), Some(APIUtil.DateWithMsExampleString))
+ ),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicResourceDocNotFound, UnknownError),
+ apiTagDynamicResourceDoc :: Nil,
+ Some(List(ApiRole.canGetDynamicResourceDoc)),
+ http4sPartialFunction = Some(getDynamicResourceDocProvenance)
+ )
+
+ val getConnectorMethodsProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "connector-methods" =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(code.connectormethod.ConnectorMethod.findAll())
+ .map(rows => JSONFactory700.ConnectorMethodsProvenanceJsonV700(
+ rows.map(JSONFactory700.createConnectorMethodProvenanceJsonV700)))
+ }
+ }
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getConnectorMethodsProvenance),
+ "GET",
+ "/management/connector-methods",
+ "Get Connector Methods (with provenance)",
+ s"""Returns all Connector Methods, each wrapped with a `provenance` object recording who created / last updated the runtime-compiled code and a SHA-256 of its method body.
+ |
+ |This is the v7.0.0 read view of the v4.0.0 Connector Methods; create / update remain on v4.0.0.
+ |
+ |${userAuthenticationMessage(true)}""".stripMargin,
+ EmptyBody,
+ JSONFactory700.ConnectorMethodsProvenanceJsonV700(Nil),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ apiTagConnectorMethod :: Nil,
+ Some(List(ApiRole.canGetAllConnectorMethods)),
+ http4sPartialFunction = Some(getConnectorMethodsProvenance)
+ )
+
+ val getConnectorMethodProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "connector-methods" / connectorMethodId =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(code.connectormethod.ConnectorMethod.find(
+ By(code.connectormethod.ConnectorMethod.ConnectorMethodId, connectorMethodId)))
+ .map(box => unboxFullOrFail(box, Some(cc), s"$ConnectorMethodNotFound Current CONNECTOR_METHOD_ID($connectorMethodId)", 404))
+ .map(JSONFactory700.createConnectorMethodProvenanceJsonV700)
+ }
+ }
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getConnectorMethodProvenance),
+ "GET",
+ "/management/connector-methods/CONNECTOR_METHOD_ID",
+ "Get Connector Method (with provenance)",
+ s"""Returns the Connector Method specified by CONNECTOR_METHOD_ID, wrapped with a `provenance` object (created_by_user_id, updated_by_user_id, method_body_hash, created_at, updated_at).
+ |
+ |This is the v7.0.0 read view of the v4.0.0 Connector Method; create / update remain on v4.0.0.
+ |
+ |${userAuthenticationMessage(true)}""".stripMargin,
+ EmptyBody,
+ JSONFactory700.ConnectorMethodProvenanceJsonV700(
+ jsonScalaConnectorMethod,
+ JSONFactory700.ProvenanceJsonV700(Some(code.api.util.ExampleValue.userIdExample.value), None, Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), Some(APIUtil.DateWithMsExampleString), Some(APIUtil.DateWithMsExampleString))
+ ),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConnectorMethodNotFound, UnknownError),
+ apiTagConnectorMethod :: Nil,
+ Some(List(ApiRole.canGetConnectorMethod)),
+ http4sPartialFunction = Some(getConnectorMethodProvenance)
+ )
+
+ val getDynamicMessageDocsProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "dynamic-message-docs" =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(code.dynamicMessageDoc.DynamicMessageDoc.findAll())
+ .map(rows => JSONFactory700.DynamicMessageDocsProvenanceJsonV700(
+ rows.map(JSONFactory700.createDynamicMessageDocProvenanceJsonV700)))
+ }
+ }
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getDynamicMessageDocsProvenance),
+ "GET",
+ "/management/dynamic-message-docs",
+ "Get Dynamic Message Docs (with provenance)",
+ s"""Returns all Dynamic Message Docs, each wrapped with a `provenance` object recording who created / last updated the runtime-compiled code and a SHA-256 of its method body.
+ |
+ |This is the v7.0.0 read view of the v4.0.0 Dynamic Message Docs; create / update / delete remain on v4.0.0.
+ |
+ |${userAuthenticationMessage(true)}""".stripMargin,
+ EmptyBody,
+ JSONFactory700.DynamicMessageDocsProvenanceJsonV700(Nil),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ apiTagDynamicMessageDoc :: Nil,
+ Some(List(ApiRole.canGetAllDynamicMessageDocs)),
+ http4sPartialFunction = Some(getDynamicMessageDocsProvenance)
+ )
+
+ val getDynamicMessageDocProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "dynamic-message-docs" / dynamicMessageDocId =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(code.dynamicMessageDoc.DynamicMessageDoc.find(
+ By(code.dynamicMessageDoc.DynamicMessageDoc.DynamicMessageDocId, dynamicMessageDocId)))
+ .map(box => unboxFullOrFail(box, Some(cc), s"$DynamicMessageDocNotFound Current DYNAMIC_MESSAGE_DOC_ID($dynamicMessageDocId)", 404))
+ .map(JSONFactory700.createDynamicMessageDocProvenanceJsonV700)
+ }
+ }
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getDynamicMessageDocProvenance),
+ "GET",
+ "/management/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID",
+ "Get Dynamic Message Doc (with provenance)",
+ s"""Returns the Dynamic Message Doc specified by DYNAMIC_MESSAGE_DOC_ID, wrapped with a `provenance` object (created_by_user_id, updated_by_user_id, method_body_hash, created_at, updated_at).
+ |
+ |This is the v7.0.0 read view of the v4.0.0 Dynamic Message Doc; create / update / delete remain on v4.0.0.
+ |
+ |${userAuthenticationMessage(true)}""".stripMargin,
+ EmptyBody,
+ JSONFactory700.DynamicMessageDocProvenanceJsonV700(
+ jsonDynamicMessageDoc,
+ JSONFactory700.ProvenanceJsonV700(Some(code.api.util.ExampleValue.userIdExample.value), None, Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), Some(APIUtil.DateWithMsExampleString), Some(APIUtil.DateWithMsExampleString))
+ ),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicMessageDocNotFound, UnknownError),
+ apiTagDynamicMessageDoc :: Nil,
+ Some(List(ApiRole.canGetDynamicMessageDoc)),
+ http4sPartialFunction = Some(getDynamicMessageDocProvenance)
+ )
+
// All routes combined (without middleware - for direct use).
//
// Routes are sorted automatically by URL template specificity (segment count,
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
index 79f4017501..44ced1f6ea 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
@@ -11,6 +11,10 @@ import code.metrics.{MappedMetric, MetricArchive, MetricsArchiveRun, MetricsProp
import code.util.Helper.MdcLoggable
import code.views.Views
import code.api.v3_1_0.{AccountAttributeResponseJson, JSONFactory310}
+import code.dynamicResourceDoc.{DynamicResourceDoc, JsonDynamicResourceDoc}
+import code.connectormethod.{ConnectorMethod, JsonConnectorMethod}
+import code.dynamicMessageDoc.{DynamicMessageDoc, JsonDynamicMessageDoc}
+import org.apache.commons.lang3.StringUtils
import com.openbankproject.commons.model.{AccountAttribute, AccountId, AccountRoutingJsonV121, AmountOfMoneyJsonV121, BankAccount, BankId, BankIdAccountId, CoreAccount, TransactionRequest, TransactionRequestCommonBodyJSON, User}
import com.openbankproject.commons.util.ApiVersion
import java.util.Date
@@ -21,6 +25,52 @@ import scala.concurrent.{ExecutionContext, Future}
object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
+ // ─── Provenance for runtime-compiled dynamic code (v7.0.0 read-only exposure) ───
+ // The v4.0.0 create/update endpoints capture who created / last updated a piece of runtime
+ // code and a SHA-256 of its (decoded) method body into DB columns, but the v4 response shape
+ // is frozen (STABLE) and does not carry them. These v7 GET endpoints expose that provenance,
+ // wrapping the unchanged v4 resource JSON alongside a `provenance` object.
+ case class ProvenanceJsonV700(
+ created_by_user_id: Option[String],
+ updated_by_user_id: Option[String],
+ method_body_hash: Option[String],
+ created_at: Option[String],
+ updated_at: Option[String]
+ )
+ case class DynamicResourceDocProvenanceJsonV700(dynamic_resource_doc: JsonDynamicResourceDoc, provenance: ProvenanceJsonV700)
+ case class DynamicResourceDocsProvenanceJsonV700(dynamic_resource_docs: List[DynamicResourceDocProvenanceJsonV700])
+ case class ConnectorMethodProvenanceJsonV700(connector_method: JsonConnectorMethod, provenance: ProvenanceJsonV700)
+ case class ConnectorMethodsProvenanceJsonV700(connector_methods: List[ConnectorMethodProvenanceJsonV700])
+ case class DynamicMessageDocProvenanceJsonV700(dynamic_message_doc: JsonDynamicMessageDoc, provenance: ProvenanceJsonV700)
+ case class DynamicMessageDocsProvenanceJsonV700(dynamic_message_docs: List[DynamicMessageDocProvenanceJsonV700])
+
+ private def blankToNone(s: String): Option[String] = Option(s).filter(StringUtils.isNotBlank)
+ private def formatDateOpt(d: Date): Option[String] = Option(d).map(APIUtil.formatDate)
+
+ def createDynamicResourceDocProvenanceJsonV700(entity: DynamicResourceDoc): DynamicResourceDocProvenanceJsonV700 =
+ DynamicResourceDocProvenanceJsonV700(
+ DynamicResourceDoc.getJsonDynamicResourceDoc(entity),
+ ProvenanceJsonV700(
+ blankToNone(entity.CreatedByUserId.get), blankToNone(entity.UpdatedByUserId.get),
+ blankToNone(entity.MethodBodyHash.get), formatDateOpt(entity.createdAt.get), formatDateOpt(entity.updatedAt.get))
+ )
+
+ def createConnectorMethodProvenanceJsonV700(entity: ConnectorMethod): ConnectorMethodProvenanceJsonV700 =
+ ConnectorMethodProvenanceJsonV700(
+ ConnectorMethod.getJsonConnectorMethod(entity),
+ ProvenanceJsonV700(
+ blankToNone(entity.CreatedByUserId.get), blankToNone(entity.UpdatedByUserId.get),
+ blankToNone(entity.MethodBodyHash.get), formatDateOpt(entity.createdAt.get), formatDateOpt(entity.updatedAt.get))
+ )
+
+ def createDynamicMessageDocProvenanceJsonV700(entity: DynamicMessageDoc): DynamicMessageDocProvenanceJsonV700 =
+ DynamicMessageDocProvenanceJsonV700(
+ DynamicMessageDoc.getJsonDynamicMessageDoc(entity),
+ ProvenanceJsonV700(
+ blankToNone(entity.CreatedByUserId.get), blankToNone(entity.UpdatedByUserId.get),
+ blankToNone(entity.MethodBodyHash.get), formatDateOpt(entity.createdAt.get), formatDateOpt(entity.updatedAt.get))
+ )
+
case class ErrorMessageEntryJsonV700(code: String, name: String, message: String)
// Cached for server lifetime: ErrorMessages is a static catalog of `val X = "OBP-NNNNN: ..."`
diff --git a/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala b/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala
index 758f5b10ba..051c515af1 100644
--- a/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala
@@ -240,7 +240,8 @@ object InternalConnector {
private def callableMethods: Map[String, MethodSymbol] = {
val dynamicMethods: Map[String, MethodSymbol] = ConnectorMethodProvider.provider.vend.getAll().map {
- case JsonConnectorMethod(_, methodName, _, _) =>
+ case v: JsonConnectorMethod =>
+ val methodName = v.methodName
methodName -> Box(methodNameToSymbols.get(methodName)).openOrThrowException(s"method name $methodName does not exist in the Connector")
}.toMap
diff --git a/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala b/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala
index ad456a9b5d..5108c09971 100644
--- a/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala
+++ b/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala
@@ -3,7 +3,7 @@ package code.connectormethod
import code.util.UUIDString
import net.liftweb.mapper._
-class ConnectorMethod extends LongKeyedMapper[ConnectorMethod] with IdPK {
+class ConnectorMethod extends LongKeyedMapper[ConnectorMethod] with IdPK with CreatedUpdated {
override def getSingleton = ConnectorMethod
@@ -13,10 +13,26 @@ class ConnectorMethod extends LongKeyedMapper[ConnectorMethod] with IdPK {
object MethodBody extends MappedText(this)
object Lang extends MappedString(this, 50)
+ // Provenance for this runtime-compiled connector method: who created / last updated it and a
+ // SHA-256 of the (decoded) method body. Set server-side from the CallContext user, never the
+ // request body. createdAt / updatedAt come from the CreatedUpdated trait.
+ object CreatedByUserId extends MappedString(this, 255)
+ object UpdatedByUserId extends MappedString(this, 255)
+ object MethodBodyHash extends MappedString(this, 64)
}
object ConnectorMethod extends ConnectorMethod with LongKeyedMetaMapper[ConnectorMethod] {
override def dbIndexes: List[BaseIndex[ConnectorMethod]] = UniqueIndex(ConnectorMethodId) :: UniqueIndex(MethodName) :: super.dbIndexes
+
+ // Note: provenance (CreatedByUserId / UpdatedByUserId / MethodBodyHash / createdAt / updatedAt) is
+ // captured in the columns above but intentionally NOT surfaced in this v4.0.0 (STABLE) JSON — the
+ // v4 response shape is frozen. It will be exposed via a new (v7) endpoint version.
+ def getJsonConnectorMethod(it: ConnectorMethod): JsonConnectorMethod = JsonConnectorMethod(
+ connectorMethodId = Some(it.ConnectorMethodId.get),
+ methodName = it.MethodName.get,
+ methodBody = it.MethodBody.get,
+ programmingLang = Option(it.Lang.get).getOrElse("Scala")
+ )
}
diff --git a/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala b/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala
index fb11b6cc82..ec020ffa36 100644
--- a/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala
+++ b/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala
@@ -29,8 +29,8 @@ trait ConnectorMethodProvider {
def getAll(): List[JsonConnectorMethod]
- def create(entity: JsonConnectorMethod): Box[JsonConnectorMethod]
- def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String): Box[JsonConnectorMethod]
+ def create(entity: JsonConnectorMethod, createdByUserId: Option[String]): Box[JsonConnectorMethod]
+ def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String, updatedByUserId: Option[String]): Box[JsonConnectorMethod]
def deleteById(connectorMethodId: String): Box[Boolean]
}
diff --git a/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala b/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala
index 426f9b047a..23a74ba0e6 100644
--- a/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala
+++ b/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala
@@ -17,17 +17,15 @@ object MappedConnectorMethodProvider extends ConnectorMethodProvider {
if(Props.testMode) 0
else APIUtil.getPropsValue(s"connectorMethod.cache.ttl.seconds", "40").toInt
}
- private def getLang(connectorMethod: ConnectorMethod): String = Option(connectorMethod.Lang.get).getOrElse("Scala")
-
override def getById(connectorMethodId: String): Box[JsonConnectorMethod] = ConnectorMethod
.find(By(ConnectorMethod.ConnectorMethodId, connectorMethodId))
- .map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it)))
+ .map(ConnectorMethod.getJsonConnectorMethod)
override def getByMethodNameWithoutCache(methodName: String): Box[JsonConnectorMethod] = {
ConnectorMethod.find(By(ConnectorMethod.MethodName, methodName))
- .map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it)))
+ .map(ConnectorMethod.getJsonConnectorMethod)
}
-
+
override def getByMethodNameWithCache(methodName: String): Box[JsonConnectorMethod] = {
var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString)
CacheKeyFromArguments.buildCacheKey {
@@ -40,27 +38,35 @@ object MappedConnectorMethodProvider extends ConnectorMethodProvider {
CacheKeyFromArguments.buildCacheKey {
Caching.memoizeSyncWithProvider (Some(cacheKey.toString())) (getConnectorMethodTTL.second) {
ConnectorMethod.findAll()
- .map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it)))
+ .map(ConnectorMethod.getJsonConnectorMethod)
}}
}
- override def create(entity: JsonConnectorMethod): Box[JsonConnectorMethod]=
+ override def create(entity: JsonConnectorMethod, createdByUserId: Option[String]): Box[JsonConnectorMethod]=
tryo {
ConnectorMethod.create
.ConnectorMethodId(APIUtil.generateUUID())
.MethodName(entity.methodName)
.MethodBody(entity.methodBody)
.Lang(entity.programmingLang)
+ // provenance is set here from the authenticated user + computed hash, not from `entity`
+ .CreatedByUserId(createdByUserId.getOrElse(null))
+ .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody))
.saveMe()
- }.map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it)))
+ }.map(ConnectorMethod.getJsonConnectorMethod)
- override def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String): Box[JsonConnectorMethod] = {
+ override def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String, updatedByUserId: Option[String]): Box[JsonConnectorMethod] = {
ConnectorMethod.find(By(ConnectorMethod.ConnectorMethodId, connectorMethodId)) match {
case Full(v) =>
tryo {
- v.MethodBody(connectorMethodBody).Lang(programmingLang).saveMe()
- }.map(it => JsonConnectorMethod(Some(connectorMethodId), it.MethodName.get, it.MethodBody.get, getLang(it)))
+ v.MethodBody(connectorMethodBody)
+ .Lang(programmingLang)
+ // CreatedByUserId is left untouched; record who last changed the code + refresh the hash
+ .UpdatedByUserId(updatedByUserId.getOrElse(null))
+ .MethodBodyHash(APIUtil.sha256Hex(java.net.URLDecoder.decode(connectorMethodBody, "UTF-8")))
+ .saveMe()
+ }.map(ConnectorMethod.getJsonConnectorMethod)
case _ => Empty
}
}
diff --git a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala
index 4aac12a801..f22e742c92 100644
--- a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala
+++ b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala
@@ -4,26 +4,33 @@ import org.json4s._
import code.util.UUIDString
import com.openbankproject.commons.util.json
import net.liftweb.mapper._
+
import scala.collection.immutable.List
-class DynamicMessageDoc extends LongKeyedMapper[DynamicMessageDoc] with IdPK {
+class DynamicMessageDoc extends LongKeyedMapper[DynamicMessageDoc] with IdPK with CreatedUpdated {
override def getSingleton = DynamicMessageDoc
object BankId extends MappedString(this, 255)
- object DynamicMessageDocId extends UUIDString(this)
- object Process extends MappedString(this, 255)
- object MessageFormat extends MappedString(this, 255)
- object Description extends MappedString(this, 255)
- object OutboundTopic extends MappedString(this, 255)
- object InboundTopic extends MappedString(this, 255)
- object ExampleOutboundMessage extends MappedText(this)
- object ExampleInboundMessage extends MappedText(this)
+ object DynamicMessageDocId extends UUIDString(this)
+ object Process extends MappedString(this, 255)
+ object MessageFormat extends MappedString(this, 255)
+ object Description extends MappedString(this, 255)
+ object OutboundTopic extends MappedString(this, 255)
+ object InboundTopic extends MappedString(this, 255)
+ object ExampleOutboundMessage extends MappedText(this)
+ object ExampleInboundMessage extends MappedText(this)
object OutboundAvroSchema extends MappedText(this)
- object InboundAvroSchema extends MappedText(this)
- object AdapterImplementation extends MappedString(this, 255)
+ object InboundAvroSchema extends MappedText(this)
+ object AdapterImplementation extends MappedString(this, 255)
object MethodBody extends MappedText(this)
object Lang extends MappedString(this, 50)
+ // Provenance for this runtime-compiled connector function: who created / last updated it and a
+ // SHA-256 of the (decoded) method body. Set server-side from the CallContext user, never the
+ // request body. createdAt / updatedAt come from the CreatedUpdated trait.
+ object CreatedByUserId extends MappedString(this, 255)
+ object UpdatedByUserId extends MappedString(this, 255)
+ object MethodBodyHash extends MappedString(this, 64)
}
diff --git a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala
index c47a94631a..d5a0edf069 100644
--- a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala
+++ b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala
@@ -41,8 +41,8 @@ trait DynamicMessageDocProvider {
def getByProcess(bankId: Option[String], process: String): Box[JsonDynamicMessageDoc]
def getAll(bankId: Option[String]): List[JsonDynamicMessageDoc]
- def create(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc]
- def update(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc]
+ def create(bankId: Option[String], entity: JsonDynamicMessageDoc, createdByUserId: Option[String]): Box[JsonDynamicMessageDoc]
+ def update(bankId: Option[String], entity: JsonDynamicMessageDoc, updatedByUserId: Option[String]): Box[JsonDynamicMessageDoc]
def deleteById(bankId: Option[String], dynamicMessageDocId: String): Box[Boolean]
}
\ No newline at end of file
diff --git a/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala b/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala
index 81240419f1..946e89edf3 100644
--- a/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala
+++ b/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala
@@ -53,7 +53,7 @@ object MappedDynamicMessageDocProvider extends DynamicMessageDocProvider {
}}
}
- override def create(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc]= {
+ override def create(bankId: Option[String], entity: JsonDynamicMessageDoc, createdByUserId: Option[String]): Box[JsonDynamicMessageDoc]= {
tryo {
DynamicMessageDoc.create
.BankId(bankId.getOrElse(null))
@@ -70,12 +70,15 @@ object MappedDynamicMessageDocProvider extends DynamicMessageDocProvider {
.AdapterImplementation(entity.adapterImplementation)
.MethodBody(entity.methodBody)
.Lang(entity.programmingLang)
+ // provenance is set here from the authenticated user + computed hash, not from `entity`
+ .CreatedByUserId(createdByUserId.getOrElse(null))
+ .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody))
.saveMe()
}.map(DynamicMessageDoc.getJsonDynamicMessageDoc)
}
- override def update(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc] = {
+ override def update(bankId: Option[String], entity: JsonDynamicMessageDoc, updatedByUserId: Option[String]): Box[JsonDynamicMessageDoc] = {
val dynamicMessageDocBox = if(bankId.isDefined){
DynamicMessageDoc.find(
By(DynamicMessageDoc.DynamicMessageDocId, entity.dynamicMessageDocId.getOrElse("")),
@@ -102,6 +105,9 @@ object MappedDynamicMessageDocProvider extends DynamicMessageDocProvider {
.AdapterImplementation(entity.adapterImplementation)
.MethodBody(entity.methodBody)
.Lang(entity.programmingLang)
+ // CreatedByUserId is left untouched; record who last changed the code + refresh the hash
+ .UpdatedByUserId(updatedByUserId.getOrElse(null))
+ .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody))
.saveMe()
}.map(DynamicMessageDoc.getJsonDynamicMessageDoc)
case _ => Empty
diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala
index 386447110c..d11c21ae41 100644
--- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala
+++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala
@@ -8,7 +8,7 @@ import org.apache.commons.lang3.StringUtils
import scala.collection.immutable.List
-class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK {
+class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK with CreatedUpdated {
override def getSingleton = DynamicResourceDoc
@@ -18,13 +18,19 @@ class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK {
object RequestVerb extends MappedString(this, 255)
object RequestUrl extends MappedString(this, 255)
object Summary extends MappedString(this, 255)
- object Description extends MappedString(this, 255)
+ object Description extends MappedString(this, 255)
object ExampleRequestBody extends MappedString(this, 255)
object SuccessResponseBody extends MappedString(this, 255)
- object ErrorResponseBodies extends MappedString(this, 255)
+ object ErrorResponseBodies extends MappedString(this, 255)
object Tags extends MappedString(this, 255)
object Roles extends MappedString(this, 255)
object MethodBody extends MappedText(this)
+ // Provenance: who created / last updated this runtime-compiled endpoint, and a SHA-256 of the
+ // (decoded) method body so tampering / drift is detectable. Set server-side from the CallContext
+ // user — never from the request body. createdAt / updatedAt come from the CreatedUpdated trait.
+ object CreatedByUserId extends MappedString(this, 255)
+ object UpdatedByUserId extends MappedString(this, 255)
+ object MethodBodyHash extends MappedString(this, 64)
}
diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala
index 9ec0927ae6..0dc263d045 100644
--- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala
+++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala
@@ -42,13 +42,13 @@ trait DynamicResourceDocProvider {
def getById(bankId: Option[String], dynamicResourceDocId: String): Box[JsonDynamicResourceDoc]
def getByVerbAndUrl(bankId: Option[String], requestVerb: String, requestUrl: String): Box[JsonDynamicResourceDoc]
-
+
def getAll(bankId: Option[String]): List[JsonDynamicResourceDoc] = getAllAndConvert(bankId, identity)
def getAllAndConvert[T: Manifest](bankId: Option[String], transform: JsonDynamicResourceDoc => T): List[T]
- def create(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc]
- def update(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc]
+ def create(bankId: Option[String], entity: JsonDynamicResourceDoc, createdByUserId: Option[String]): Box[JsonDynamicResourceDoc]
+ def update(bankId: Option[String], entity: JsonDynamicResourceDoc, updatedByUserId: Option[String]): Box[JsonDynamicResourceDoc]
def deleteById(bankId: Option[String], dynamicResourceDocId: String): Box[Boolean]
}
diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala
index e2b86c544e..47be5d0442 100644
--- a/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala
+++ b/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala
@@ -63,7 +63,7 @@ object MappedDynamicResourceDocProvider extends DynamicResourceDocProvider {
}
}
- override def create(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc]=
+ override def create(bankId: Option[String], entity: JsonDynamicResourceDoc, createdByUserId: Option[String]): Box[JsonDynamicResourceDoc]=
tryo {
val requestBody = entity.exampleRequestBody.map(json.compactRender(_)).orNull
val responseBody = entity.successResponseBody.map(json.compactRender(_)).orNull
@@ -82,11 +82,14 @@ object MappedDynamicResourceDocProvider extends DynamicResourceDocProvider {
.Tags(entity.tags)
.Roles(entity.roles)
.MethodBody(entity.methodBody)
+ // provenance is set here from the authenticated user + computed hash, not from `entity`
+ .CreatedByUserId(createdByUserId.getOrElse(null))
+ .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody))
.saveMe()
}.map(DynamicResourceDoc.getJsonDynamicResourceDoc)
- override def update(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc] = {
+ override def update(bankId: Option[String], entity: JsonDynamicResourceDoc, updatedByUserId: Option[String]): Box[JsonDynamicResourceDoc] = {
DynamicResourceDoc.find(By(DynamicResourceDoc.DynamicResourceDocId, entity.dynamicResourceDocId.getOrElse(""))) match {
case Full(v) =>
tryo {
@@ -104,6 +107,9 @@ object MappedDynamicResourceDocProvider extends DynamicResourceDocProvider {
.Tags(entity.tags)
.Roles(entity.roles)
.MethodBody(entity.methodBody)
+ // CreatedByUserId is left untouched; record who last changed the code + refresh the hash
+ .UpdatedByUserId(updatedByUserId.getOrElse(null))
+ .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody))
.saveMe()
}.map(DynamicResourceDoc.getJsonDynamicResourceDoc)
case _ => Empty
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala
index a2da8727af..4fad2e1353 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala
@@ -99,6 +99,13 @@ class ConnectorMethodTest extends V400ServerSetup {
connectorMethod.methodBody should be (postConnectorMethod.methodBody)
connectorMethod.connectorMethodId shouldNot be (null)
+ Then("provenance is captured server-side into the stored row (not surfaced in the frozen v4 response)")
+ val storedConnectorMethod = code.connectormethod.ConnectorMethod
+ .find(net.liftweb.mapper.By(code.connectormethod.ConnectorMethod.ConnectorMethodId, connectorMethod.connectorMethodId.getOrElse("")))
+ .openOrThrowException("stored connector method not found")
+ storedConnectorMethod.CreatedByUserId.get should be (resourceUser1.userId)
+ storedConnectorMethod.MethodBodyHash.get should be (code.api.util.APIUtil.sha256Hex(postConnectorMethod.decodedMethodBody))
+
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateMethodRouting.toString)
val requestCreateMethodRouting = (v4_0_0_Request / "management" / "method_routings").POST <@(user1)
@@ -279,7 +286,7 @@ class ConnectorMethodTest extends V400ServerSetup {
|)
|""".stripMargin
val encodedMethodBody = URLEncoder.encode(methodBody, "UTF-8")
- ConnectorMethodProvider.provider.vend.create(JsonConnectorMethod(Some("Hello_bank_id"), "getBank", encodedMethodBody))
+ ConnectorMethodProvider.provider.vend.create(JsonConnectorMethod(Some("Hello_bank_id"), "getBank", encodedMethodBody), None)
val connectorMethod = InternalConnector.instance
Then("Call dynamic method")
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala
index 2c53c8af95..8935de7b33 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala
@@ -97,6 +97,13 @@ class DynamicMessageDocTest extends V400ServerSetup {
dynamicMessageDoc.inboundAvroSchema should be (postDynamicMessageDoc.inboundAvroSchema)
dynamicMessageDoc.adapterImplementation should be (postDynamicMessageDoc.adapterImplementation)
+ Then("provenance is captured server-side into the stored row (not surfaced in the frozen v4 response)")
+ val storedMessageDoc = code.dynamicMessageDoc.DynamicMessageDoc
+ .find(net.liftweb.mapper.By(code.dynamicMessageDoc.DynamicMessageDoc.DynamicMessageDocId, dynamicMessageDoc.dynamicMessageDocId.getOrElse("")))
+ .openOrThrowException("stored dynamic message doc not found")
+ storedMessageDoc.CreatedByUserId.get should be (resourceUser1.userId)
+ storedMessageDoc.MethodBodyHash.get should be (code.api.util.APIUtil.sha256Hex(postDynamicMessageDoc.decodedMethodBody))
+
Then(s"we test the $ApiEndpoint2")
val requestGet = (v4_0_0_Request / "management" / "dynamic-message-docs" / {dynamicMessageDoc.dynamicMessageDocId.getOrElse("")}).GET <@ (user1)
@@ -292,7 +299,7 @@ class DynamicMessageDocTest extends V400ServerSetup {
)
When("We store the DynamicMessageDoc via the provider")
- DynamicMessageDocProvider.provider.vend.create(None, doc).isDefined should equal(true)
+ DynamicMessageDocProvider.provider.vend.create(None, doc, None).isDefined should equal(true)
Then("DynamicConnector.invoke compiles the stored methodBody and runs the connector method")
val fut = DynamicConnector
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala
index 70d4f035bf..a482557a18 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala
@@ -42,6 +42,8 @@ import org.json4s.JArray
import org.json4s.native.Serialization.write
import org.scalatest.Tag
+import java.net.{URLDecoder, URLEncoder}
+
class DynamicResourceDocTest extends V400ServerSetup {
@@ -329,6 +331,101 @@ class DynamicResourceDocTest extends V400ServerSetup {
resp200.code should equal(200)
json.compactRender(resp200.body) should include("_from_path")
}
+
+ // Regression guard for DynamicEndpointCodeGenerator.buildTemplate: the template served by
+ // POST /management/dynamic-resource-docs/endpoint-code must emit the NATIVE contract
+ // (Request[IO] / IO[Response[IO]] / callContext.httpBody / errorResponse), so the documented
+ // workflow — copy the generated process body into a dynamic resource doc's method_body —
+ // yields code that compiles and serves. The template previously emitted the retired Lift
+ // contract (Box[JsonResponse], request.json, errorJsonResponse), which no longer compiles.
+ scenario("The generated endpoint-code template compiles and serves as a dynamic resource doc method body", ApiEndpoint1, VersionOfApi) {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString)
+
+ When("We generate the endpoint code template for a POST endpoint with example bodies")
+ val fragment = SwaggerDefinitionsJSON.jsonResourceDocFragment.copy(
+ requestVerb = "POST",
+ requestUrl = "/template_gen_user/TEMPLATE_USER_ID"
+ )
+ val codeReq = (v4_0_0_Request / "management" / "dynamic-resource-docs" / "endpoint-code").POST <@ (user1)
+ val codeResp = makePostRequest(codeReq, write(fragment))
+ codeResp.code should equal(201)
+ val template = URLDecoder.decode((codeResp.body \ "code").values.toString, "UTF-8")
+
+ Then("the template declares the native process signature, not the retired Lift one")
+ template should include("override protected def process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]): IO[Response[IO]]")
+ template should include("callContext.httpBody")
+ template should include("errorResponse(")
+ template should not include "Box[JsonResponse]"
+ template should not include "request.json"
+ template should not include "errorJsonResponse"
+ template should not include "getPathParams(callContext, request)"
+
+ Then("the process body sliced from the template compiles as a dynamic resource doc method body (201)")
+ val marker = "IO[Response[IO]] = {"
+ val processBody = template.substring(template.indexOf(marker) + marker.length, template.lastIndexOf("}"))
+ val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1)
+ val doc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(
+ dynamicResourceDocId = None,
+ bankId = None,
+ roles = "",
+ partialFunctionName = "generatedTemplateTest",
+ requestUrl = "/template_gen_user/TEMPLATE_USER_ID",
+ methodBody = URLEncoder.encode(processBody, "UTF-8"),
+ exampleRequestBody = fragment.exampleRequestBody,
+ successResponseBody = fragment.successResponseBody
+ )
+ makePostRequest(createReq, write(doc)).code should equal(201)
+
+ Then("calling the served endpoint with a valid body returns 200 (the template's placeholder business logic)")
+ val callReq = (dynamicEndpoint_Request / "dynamic-resource-doc" / "template_gen_user" / "user-1").POST <@ (user1)
+ makePostRequest(callReq, """{"name":"Jhon","age":12,"hobby":["coding"]}""").code should equal(200)
+
+ Then("calling without a body returns 400 via the template's errorResponse early-exit")
+ makePostRequest(callReq, "").code should equal(400)
+ }
+ }
+
+ // Provenance is captured server-side into the DB columns but intentionally NOT surfaced in the
+ // v4.0.0 (STABLE) response JSON — the v4 shape is frozen, so we assert against the stored entity,
+ // not the response. (Exposure of these fields is planned for a new, v7, endpoint version.)
+ feature("Provenance is captured on runtime-compiled dynamic resource docs") {
+
+ scenario("Create stores created_by_user_id + method_body hash; update records the updater and refreshes the hash", ApiEndpoint1, ApiEndpoint2, VersionOfApi) {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString)
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canUpdateDynamicResourceDoc.toString)
+
+ When("We create a dynamic resource doc")
+ val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1)
+ val posted = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(
+ dynamicResourceDocId = None,
+ bankId = None,
+ partialFunctionName = "provenanceTest",
+ requestUrl = "/provenance_test_user/MY_USER_ID"
+ )
+ val createResp = makePostRequest(createReq, write(posted))
+ createResp.code should equal(201)
+ val docId = (createResp.body \ "dynamic_resource_doc_id").values.toString
+
+ Then("the stored row records the authenticated caller and the server-computed SHA-256 of the decoded body")
+ def storedRow = code.dynamicResourceDoc.DynamicResourceDoc
+ .find(net.liftweb.mapper.By(code.dynamicResourceDoc.DynamicResourceDoc.DynamicResourceDocId, docId))
+ .openOrThrowException("stored dynamic resource doc not found")
+ storedRow.CreatedByUserId.get should be(resourceUser1.userId)
+ storedRow.MethodBodyHash.get should be(code.api.util.APIUtil.sha256Hex(posted.decodedMethodBody))
+
+ When("We update the doc with a changed method body")
+ val changedMethodBody = URLEncoder.encode(
+ URLDecoder.decode(posted.methodBody, "UTF-8") + "\n // a change\n", "UTF-8")
+ val updateReq = (v4_0_0_Request / "management" / "dynamic-resource-docs" / docId).PUT <@ (user1)
+ val updateResp = makePutRequest(updateReq,
+ write(posted.copy(dynamicResourceDocId = Some(docId), methodBody = changedMethodBody)))
+ updateResp.code should equal(200)
+
+ Then("created_by_user_id is preserved, updated_by_user_id is recorded, and the hash reflects the new body")
+ storedRow.CreatedByUserId.get should be(resourceUser1.userId)
+ storedRow.UpdatedByUserId.get should be(resourceUser1.userId)
+ storedRow.MethodBodyHash.get should be(code.api.util.APIUtil.sha256Hex(URLDecoder.decode(changedMethodBody, "UTF-8")))
+ }
}
}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
index 37411b2d08..5dbecd26a8 100644
--- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
+++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
@@ -2218,6 +2218,96 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
case _ => fail("Expected JSON object")
}
+ // ─── Dynamic-code provenance (v7.0.0 read-only) ──────────────────────────────
+ feature("Http4s700 dynamic-code provenance endpoints") {
+
+ scenario("Dynamic Resource Docs: 401 unauth, 403 no role, 200 with role exposes provenance", Http4s700RoutesTag) {
+ Given("A dynamic resource doc seeded with resourceUser1 as creator")
+ val seeded = code.dynamicResourceDoc.DynamicResourceDocProvider.provider.vend.create(
+ None,
+ code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(
+ dynamicResourceDocId = None, bankId = None,
+ partialFunctionName = "provenanceV7Test", requestUrl = "/provenance_v7/PV_ID"),
+ Some(resourceUser1.userId)
+ ).openOrThrowException("seed dynamic resource doc")
+ val docId = seeded.dynamicResourceDocId.getOrElse(fail("seeded id"))
+ val expectedHash = code.api.util.APIUtil.sha256Hex(seeded.decodedMethodBody)
+
+ When("Unauthenticated GET of the list")
+ val (unauthCode, _, _) = makeHttpRequest("/obp/v7.0.0/management/dynamic-resource-docs")
+ Then("401")
+ unauthCode shouldBe 401
+
+ When("Authenticated but without the role")
+ val (forbiddenCode, forbiddenJson, _) = makeHttpRequest(
+ "/obp/v7.0.0/management/dynamic-resource-docs", Map("DirectLogin" -> s"token=${token2.value}"))
+ Then("403 naming the required role")
+ forbiddenCode shouldBe 403
+ messageOf(forbiddenJson) should include(code.api.util.ApiRole.canGetAllDynamicResourceDocs.toString)
+
+ When("Authenticated with the getAll role")
+ addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetAllDynamicResourceDocs.toString)
+ val (okCode, okJson, _) = makeHttpRequest(
+ "/obp/v7.0.0/management/dynamic-resource-docs", Map("DirectLogin" -> s"token=${token1.value}"))
+ Then("200 and the seeded doc carries provenance (creator + method_body hash), not on the frozen v4 doc object")
+ okCode shouldBe 200
+ val item = (okJson \ "dynamic_resource_docs") match {
+ case JArray(items) => items.find(i => (i \ "dynamic_resource_doc" \ "dynamic_resource_doc_id") == JString(docId))
+ .getOrElse(fail("seeded doc not in list"))
+ case _ => fail("dynamic_resource_docs should be an array")
+ }
+ (item \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId)
+ (item \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash)
+
+ When("GET by id with the get role")
+ addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetDynamicResourceDoc.toString)
+ val (byIdCode, byIdJson, _) = makeHttpRequest(
+ s"/obp/v7.0.0/management/dynamic-resource-docs/$docId", Map("DirectLogin" -> s"token=${token1.value}"))
+ Then("200 with provenance and the unchanged v4 doc shape nested under dynamic_resource_doc")
+ byIdCode shouldBe 200
+ (byIdJson \ "dynamic_resource_doc" \ "dynamic_resource_doc_id") shouldBe JString(docId)
+ (byIdJson \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId)
+ (byIdJson \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash)
+ }
+
+ scenario("Connector Methods: GET by id exposes provenance", Http4s700RoutesTag) {
+ val seeded = code.connectormethod.ConnectorMethodProvider.provider.vend.create(
+ code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.jsonScalaConnectorMethod.copy(
+ connectorMethodId = None, methodName = "getBanks"),
+ Some(resourceUser1.userId)
+ ).openOrThrowException("seed connector method")
+ val id = seeded.connectorMethodId.getOrElse(fail("seeded id"))
+ val expectedHash = code.api.util.APIUtil.sha256Hex(seeded.decodedMethodBody)
+
+ addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetConnectorMethod.toString)
+ val (code200, json, _) = makeHttpRequest(
+ s"/obp/v7.0.0/management/connector-methods/$id", Map("DirectLogin" -> s"token=${token1.value}"))
+ code200 shouldBe 200
+ (json \ "connector_method" \ "connector_method_id") shouldBe JString(id)
+ (json \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId)
+ (json \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash)
+ }
+
+ scenario("Dynamic Message Docs: GET by id exposes provenance", Http4s700RoutesTag) {
+ val seeded = code.dynamicMessageDoc.DynamicMessageDocProvider.provider.vend.create(
+ None,
+ code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.jsonDynamicMessageDoc.copy(
+ dynamicMessageDocId = None, bankId = None, process = "obp.provenanceV7Process"),
+ Some(resourceUser1.userId)
+ ).openOrThrowException("seed dynamic message doc")
+ val id = seeded.dynamicMessageDocId.getOrElse(fail("seeded id"))
+ val expectedHash = code.api.util.APIUtil.sha256Hex(seeded.decodedMethodBody)
+
+ addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetDynamicMessageDoc.toString)
+ val (code200, json, _) = makeHttpRequest(
+ s"/obp/v7.0.0/management/dynamic-message-docs/$id", Map("DirectLogin" -> s"token=${token1.value}"))
+ code200 shouldBe 200
+ (json \ "dynamic_message_doc" \ "dynamic_message_doc_id") shouldBe JString(id)
+ (json \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId)
+ (json \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash)
+ }
+ }
+
feature("Http4s700 attachOpenCorridorPromise (promise report-back) endpoint") {
scenario("Reject unauthenticated POST", Http4s700RoutesTag) {
diff --git a/scripts/check_lift_http4s_resource_doc_parity.py b/scripts/check_lift_http4s_resource_doc_parity.py
index 352e5e421c..c400cca811 100755
--- a/scripts/check_lift_http4s_resource_doc_parity.py
+++ b/scripts/check_lift_http4s_resource_doc_parity.py
@@ -36,7 +36,9 @@
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "obp-api" / "src" / "main" / "scala" / "code" / "api"
-# Positional fields in ResourceDoc(...) — see APIUtil.scala:1589.
+# Positional fields in the pre-teardown ResourceDoc(...) signature, which is what
+# the commented-out Lift baselines still use — the endpoint partial function was
+# the first parameter.
POSITIONAL_FIELDS = [
"partialFunction", # 0
"implementedInApiVersion", # 1
@@ -51,6 +53,15 @@
"tags", # 10
]
+# Current signature (the Lift teardown removed the leading partialFunction
+# parameter — see CLAUDE.md Rule 1); used by all active Http4s*.scala files.
+CURRENT_POSITIONAL_FIELDS = POSITIONAL_FIELDS[1:]
+
+# The verb literal is the discriminator between the two signatures: it sits at
+# positional index 3 in the old signature and index 2 in the current one.
+HTTP_VERB_LITERALS = {'"%s"' % v for v in
+ ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS")}
+
# Default fields included in the diff report.
DEFAULT_DIFF_FIELDS = [
"requestVerb",
@@ -268,11 +279,17 @@ def parse_resourcedoc(body: str):
else:
named[n] = v
out = OrderedDict()
- for fname, val in zip(POSITIONAL_FIELDS, positional):
+ # Detect which constructor signature this doc uses by where the verb
+ # literal sits: index 2 → current signature (no leading partialFunction),
+ # index 3 → old signature (Lift baseline comments).
+ fields = POSITIONAL_FIELDS
+ if len(positional) > 2 and positional[2].strip() in HTTP_VERB_LITERALS:
+ fields = CURRENT_POSITIONAL_FIELDS
+ for fname, val in zip(fields, positional):
out[fname] = val
- if len(positional) > len(POSITIONAL_FIELDS):
- # 12th positional is roles (Option[List[ApiRole]]).
- extra = positional[len(POSITIONAL_FIELDS):]
+ if len(positional) > len(fields):
+ # Next positional after tags is roles (Option[List[ApiRole]]).
+ extra = positional[len(fields):]
if "roles" not in named and extra:
named["roles"] = extra[0]
out.update(named)