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: + | + |![How Opey, Claude Code and OBP-MCP call OBP-API](https://github.com/user-attachments/assets/d3ff5c10-7167-4034-98f7-c53a323bf985) + | + |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. | + |![How Opey, Claude Code and OBP-MCP call OBP-API](https://github.com/user-attachments/assets/d3ff5c10-7167-4034-98f7-c53a323bf985) + | + |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)