From c7dccf152524555b1a0f8f9dc79f4ecca4dad6b0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 23 Jul 2026 14:48:44 +0200 Subject: [PATCH 1/7] Create AgentDelegationTest.scala --- .../code/api/util/AgentDelegationTest.scala | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala diff --git a/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala new file mode 100644 index 0000000000..3171733e8b --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/AgentDelegationTest.scala @@ -0,0 +1,113 @@ +package code.api.util + +import code.api.util.APIUtil.generateUUID +import code.consent.MappedConsent +import code.model.dataAccess.ResourceUser +import code.setup.ServerSetup +import code.users.Users +import net.liftweb.common.Full +import org.scalatest.Tag + +/** + * Tests for the agent-on-behalf-of-human delegation primitives: + * + * - LiftUsers.createResourceUser field assignments — pins the 2021 copy-paste bug where + * the createdByUserInvitationId None branch wiped CreatedByConsentId (the consent → + * agent linkage every delegation query joins through). + * - CallContext.effectiveHumanUserId — resolve-up from the authenticated caller (human + * or consent-minted agent) to the human the request is really about, including the + * branch an HTTP test cannot reach (the agent as the caller). + */ +class AgentDelegationTest extends ServerSetup { + + object AgentDelegationTag extends Tag("AgentDelegation") + + private def createUser( + createdByConsentId: Option[String] = None, + createdByUserInvitationId: Option[String] = None + ): ResourceUser = + Users.users.vend.createResourceUser( + provider = "agent-delegation-test-provider", + providerId = Some(generateUUID()), + createdByConsentId = createdByConsentId, + name = Some("agent-delegation-test-user"), + email = None, + userId = None, + createdByUserInvitationId = createdByUserInvitationId, + company = None, + lastMarketingAgreementSignedDate = None + ).openOrThrowException("Expected resource user to be created") + + private def storedField(value: String): String = Option(value).getOrElse("") + + feature("createResourceUser stores CreatedByConsentId and CreatedByUserInvitationId independently") { + + scenario("consent id only — survives the invitation-id None branch", AgentDelegationTag) { + val consentId = generateUUID() + val user = createUser(createdByConsentId = Some(consentId)) + storedField(user.CreatedByConsentId.get) shouldBe consentId + storedField(user.CreatedByUserInvitationId.get) shouldBe "" + } + + scenario("invitation id only", AgentDelegationTag) { + val invitationId = generateUUID() + val user = createUser(createdByUserInvitationId = Some(invitationId)) + storedField(user.CreatedByConsentId.get) shouldBe "" + storedField(user.CreatedByUserInvitationId.get) shouldBe invitationId + } + + scenario("both ids set", AgentDelegationTag) { + val consentId = generateUUID() + val invitationId = generateUUID() + val user = createUser(Some(consentId), Some(invitationId)) + storedField(user.CreatedByConsentId.get) shouldBe consentId + storedField(user.CreatedByUserInvitationId.get) shouldBe invitationId + } + + scenario("neither id set", AgentDelegationTag) { + val user = createUser() + storedField(user.CreatedByConsentId.get) shouldBe "" + storedField(user.CreatedByUserInvitationId.get) shouldBe "" + } + } + + feature("CallContext.effectiveHumanUserId resolves the caller to the human the request is about") { + + scenario("a plain human resolves to themselves", AgentDelegationTag) { + val human = createUser() + CallContext(user = Full(human)).effectiveHumanUserId shouldBe human.userId + } + + scenario("a consent-minted agent resolves to the granting human", AgentDelegationTag) { + val human = createUser() + val consent = MappedConsent.create.mUserId(human.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + CallContext(user = Full(agent)).effectiveHumanUserId shouldBe human.userId + } + + scenario("an agent with a dangling consent id falls back to itself (fails closed)", AgentDelegationTag) { + val agent = createUser(createdByConsentId = Some(generateUUID())) + CallContext(user = Full(agent)).effectiveHumanUserId shouldBe agent.userId + } + + scenario("a populated consenter box wins over the DB chain", AgentDelegationTag) { + val chainHuman = createUser() + val consent = MappedConsent.create.mUserId(chainHuman.userId).saveMe() + val agent = createUser(createdByConsentId = Some(consent.consentId)) + val consenterHuman = createUser() + CallContext(user = Full(agent), consenter = Full(consenterHuman)) + .effectiveHumanUserId shouldBe consenterHuman.userId + } + + scenario("onBehalfOfUser wins over consenter", AgentDelegationTag) { + val agent = createUser() + val consenterHuman = createUser() + val explicitHuman = createUser() + CallContext( + user = Full(agent), + consenter = Full(consenterHuman), + onBehalfOfUser = Full(explicitHuman) + ).effectiveHumanUserId shouldBe explicitHuman.userId + } + } +} From 022c5e63cc01c265281ac559bcfa61309520232a Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 28 Jul 2026 09:26:23 +0200 Subject: [PATCH 2/7] Open Corridor related --- OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md | 483 ++++++++++++++++++ OPEN_CORRIDOR_SIMPLE_NETTING.md | 293 +++++++++++ .../main/scala/bootstrap/liftweb/Boot.scala | 2 + .../main/scala/code/api/util/ApiRole.scala | 10 + .../scala/code/api/util/ErrorMessages.scala | 5 + .../scala/code/api/v7_0_0/Http4s700.scala | 197 ++++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 55 +- .../bankconnectors/LocalMappedConnector.scala | 87 ++-- .../opencorridor/OpenCorridorBankBroker.scala | 69 +++ .../opencorridor/OpenCorridorProcessor.scala | 118 ++++- .../opencorridor/OpenCorridorPublisher.scala | 218 ++++++++ .../rabbitmq/RabbitMQConnector_vOct2024.scala | 100 +++- .../MappedTransactionRequestProvider.scala | 3 + .../code/api/v7_0_0/Http4s700RoutesTest.scala | 207 +++++++- .../commons/dto/OpenCorridorInterfaceC.scala | 130 +++++ 15 files changed, 1906 insertions(+), 71 deletions(-) create mode 100644 OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md create mode 100644 OPEN_CORRIDOR_SIMPLE_NETTING.md create mode 100644 obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala create mode 100644 obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala create mode 100644 obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala diff --git a/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md b/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md new file mode 100644 index 0000000000..b72cf2d967 --- /dev/null +++ b/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md @@ -0,0 +1,483 @@ +# Open Corridor — OBP-API: publish the Interface C messages + +**Audience:** an agent implementing the OBP-API (Scala) side of Open Corridor. +**Scope:** make OBP-API *record a promise* and *publish the RabbitMQ messages* +that the OBP Bank Node already consumes — `credit_notification` (carrying the +commitment salt) and `settlement_instruction`. + +> **Production, not PoC (directive 2026-07-18).** This doc previously framed +> the build as a proof-of-concept and deferred parts "for the PoC". That +> framing is retired: this is production development. Where a shortcut was +> PoC-justified it is now a requirement — see §5.2 (per-bank broker +> connections), §5.3 (transactional outbox), §8. The still-legitimate +> narrowings are *scope* (one corridor, one currency, bilateral, +> settle-on-demand), not *quality or durability*. + +This is the **gating piece** of the corridor build. The Bank Node side is built and +verified; what's missing is OBP-API actually sending these messages. + +> **How to use this doc:** Section 4 is a **locked wire contract** — the Bank +> Node consumer is already implemented against it, so build OBP-API to match it +> exactly (field names, the OBP envelope, the salt fields). Sections 5–6 are the +> OBP-API build, in order. Confirm current line numbers before editing (cited +> from a survey on 2026-06-14). + +> **Updated 2026-07-18** to align with `OPEN_CORRIDOR_SIMPLE_NETTING.md` +> (bilateral settle-on-demand netting on the TransactionRequest model), which +> postdates this plan. Two changes: §5.1 stores promise state on the +> **TransactionRequest** (held at `PENDING`; there is no posted Transaction to +> carry a `PROMISED` status until the settle step), and §5.3's trigger is the +> **settle-pair endpoint** (one pending promise is simply the degenerate case). +> Also since 2026-06-14: the Bank Node consumer transport is +> integration-tested against a real RabbitMQ (every §4.2 error code +> exercised), and the `settlement_instruction` → ADA transfer path has run +> live on Cardano preprod. The wire contract in §4 is proven on the consuming +> side. + +> **Updated 2026-07-18 (second pass — naming & settlement-TR decisions):** +> (1) **TR types renamed**: promise = `OPEN_CORRIDOR_PROMISE` (was +> `OPEN_CORRIDOR`), plus a new `OPEN_CORRIDOR_SETTLEMENT` type — see §5.0 +> for the rename inventory and `OPEN_CORRIDOR_SIMPLE_NETTING.md` §0 for the +> scheme definition that justifies the prefix. (2) **The settle step mints +> an internal `OPEN_CORRIDOR_SETTLEMENT` TransactionRequest** ("TR B") whose +> execution posts the net Transaction — TR-first convention; TR B doubles as +> the settle-event audit object. (3) **Discharge linkage**: the net +> Transaction's id is recorded on each covered promise TR as a +> `settled_by_transaction_ids` TR attribute; `transaction_ids` keeps its +> causal meaning (empty on promises, populated on TR B). Details: +> `OPEN_CORRIDOR_SIMPLE_NETTING.md` §4a. + +--- + +## 1. Goal and the end-to-end loop + +A single Open Corridor payment, Bank A → Bank B, settled on Cardano. Bilateral +settle-on-demand netting per `OPEN_CORRIDOR_SIMPLE_NETTING.md` — promises +accumulate as `PENDING` TRs and an admin settle-pair collapses them; with one +pending promise the net *is* that amount (the degenerate case). Every OBP-API +piece is real. + +``` +1. Bank A CBS ──A1──▶ Bank A Node ──B(submit OPEN_CORRIDOR_PROMISE TR)──▶ OBP-API [TR endpoint EXISTS] +2. Bank A Node ── writes Promise commitment to Cardano (it holds the keys) +3. Bank A Node ── reports {tx_hash, blockchain, commitment, salt, preimage} ──▶ OBP-API [BUILT: §5.1] +4. OBP-API ── admin settle-pair: net = SUM(A→B) − SUM(B→A), then PUBLISHES: [NEW: §5.2–5.3] + • obp_credit_notification (+salt) ──▶ Bank B Node ──A2──▶ Bank B CBS + • obp_settlement_instruction (net) ──▶ Bank A Node ── settles ADA ──▶ Cardano +5. Bank B recomputes SHA-256(salt‖preimage) == commitment, finds it on-chain. Proof closed. +``` + +## 2. Division of labour (non-negotiable) + +- **Bank Node holds the bank's keys** and performs **all** Cardano writes + (promise, settlement). It is a separate Rust service at + `~/Documents/workspace_2024/OBP-Bank-Node`. +- **OBP-API never signs or writes to Cardano as the bank.** It records promise + references that the node reports back, nets, and publishes the RabbitMQ + messages. It *may* read the chain to verify, never to act on the bank's behalf. +- **Do not mock OBP-API functionality.** Everything in this doc is built for real + in this codebase. (A mock *CBS* is fine — that's the bank's system, not OBP-API.) +- **Robustness rule:** the flow may be a thin slice, but OBP-API code is + production-grade — schema, state machine, audit, MessageDocs, tests. See the + companion `OBP_API_CHANGES.md` (in the Bank Node repo) for the full netting + build; this doc is the *publishing subset* needed first. + +## 3. What exists in OBP-API today (survey 2026-06-14) + +- **`createTransactionRequestOpenCorridor`** — v7.0.0 endpoint at + `obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala:3183`. Today it is + "SIMPLE + a mandatory `originator` block" for Travel Rule. It creates a + standard `TransactionRequest` of type `OPEN_CORRIDOR` (renamed to + `OPEN_CORRIDOR_PROMISE`, decision 2026-07-18 — §5.0) + (`obp-commons/.../model/enums/Enumerations.scala:129`) and persists the + originator. It does **not** record a promise status, write to Cardano, publish + anything, or compute a salt/commitment. +- **`OpenCorridorProcessor.scala`** + (`obp-api/src/main/scala/code/bankconnectors/opencorridor/`) — the OC home; its + header comment explicitly notes Cardano Promise / netting / settlement are + future extensions. +- **RabbitMQ connector is client-only RPC.** + `RabbitMQUtils.sendRequestUndGetResponseFromRabbitMQ` (`:90`) publishes a + request to `obp_rpc_queue` and awaits a reply on a per-request `replyTo` queue, + correlating by `correlationId` — exactly the shape we need, but today it's only + used for OBP-API→adapter calls. `RabbitMQConnectionPool` is single-vhost. There + are **no** Open Corridor outbound messages, no netting engine, no snapshot + table, no settlement, no transactional outbox. + +**Implication:** the "server-initiated RPC" we need is *structurally the same +publish+await-reply* the connector already does — the new work is (a) connecting +to the **bank's** vhost, (b) the new message DTOs, and (c) the trigger. + +## 4. LOCKED wire contract (build OBP-API to match this exactly) + +The Bank Node consumer is implemented against this. Source of truth: +`OBP-Bank-Node/crates/obp-bank-node/src/interface_c/types.rs` (+ `router.rs`). + +### 4.1 Transport / topology + +- Each bank consumes on **its own vhost**, e.g. `/bank.ke.01.kcs`. +- Queue name: **`obp_rpc_queue`** (the bank's `request_queue`). +- OBP-API publishes one message with AMQP properties: + - `messageId` = the operation (the four values below), + - `correlationId` = a UUID, + - `replyTo` = an OBP-API-side reply queue (per request), + then awaits the bank's reply on `replyTo`, matched by `correlationId`. +- `MessageId` values: `obp_credit_notification`, `obp_settlement_instruction`, + `obp_netting_snapshot`, `obp_status_update`. + +### 4.2 Reply envelope (what OBP-API receives back) + +```json +{ + "inboundAdapterCallContext": { "correlationId": "" }, + "status": { "errorCode": "", "backendMessages": [] }, + "data": { } +} +``` + +`errorCode == ""` means success. Non-empty `errorCode` is a failure the Bank Node +reports — OBP-API must handle these (do **not** treat the payment as delivered): + +| `errorCode` | Meaning | +|---|---| +| `OBP-BANK-NODE-COMMITMENT-MISMATCH` | salt+preimage did **not** hash to the commitment — bank refused to credit | +| `OBP-BANK-NODE-CBS-DELIVERY-FAILED` | bank's CBS did not accept the credit | +| `OBP-BANK-NODE-SETTLEMENT-FAILED` | on-chain settlement transfer failed | +| `OBP-BANK-NODE-SETTLEMENT-NOT-CONFIGURED` | that node has no settlement rail | +| `OBP-BANK-NODE-BAD-MESSAGE` | OBP-API sent a malformed body | +| `OBP-BANK-NODE-NOT-IMPLEMENTED` | unknown `messageId` | + +### 4.3 `obp_credit_notification` (publish to **Bank B**'s vhost) + +The body is `lower_snake_case`. The **three evidence fields are the point** — they +carry the commit–reveal data so Bank B can open Bank A's on-chain commitment +without Bank A's cooperation. + +```json +{ + "transaction_request_id": "tr-abc-123", + "value": { "currency": "KES", "amount": "1500.00" }, + "description": "Invoice 4471", + "originator": { "name": "Acme Coffee Ltd", "address": "Nairobi" }, + "netting_snapshot_id": "snap-1", + "promise_id": "", + "promise_blockchain": "cardano", + "promise_commitment": "", + "promise_salt": "", + "promise_preimage": "" +} +``` + +Bank B recomputes `SHA-256(promise_salt ‖ promise_preimage)` and compares to +`promise_commitment`. **On mismatch it returns `COMMITMENT-MISMATCH` and does not +credit.** On success, reply `data` = `{ transaction_request_id, verified, +cbs_reference }`. + +> `promise_commitment`, `promise_salt`, `promise_preimage` are **opaque** to +> OBP-API — it just relays what Bank A reported (§5.1). OBP-API does not need to +> know the preimage format. Treat them as strings. + +### 4.4 `obp_settlement_instruction` (publish to the **debtor**'s vhost — Bank A) + +```json +{ + "snapshot_id": "snap-1", + "settlement_id": "settle-1", + "settlement_system": "cardano-ada", + "currency": "KES", + "amount": "1500.00", + "creditor_bank_id": "gh.29.uk", + "creditor_address": "", + "idempotency_key": "settle-1" +} +``` + +`amount` is **major units** (the Bank Node parses to minor units itself, assuming +2 decimals — a documented limitation to revisit for non-2-exponent currencies). +The debtor node settles from its own wallet. + +**Idempotency + finality semantics (Bank Node behaviour as of 2026-07-18):** + +- `idempotency_key` (falling back to `settlement_id`) is **required** — a + message with neither is refused as `OBP-BANK-NODE-BAD-MESSAGE`. The node + keeps a durable settlement record per key and will **never pay twice** for + the same key. +- The success reply `data` is + `{ settlement_id, status, tx_id, blockchain, asset, asset_amount, depth, + finality_depth }`. `status` is one of: + - `SUBMITTED` — broadcast to the chain, **not yet final**; + - `FINAL` — confirmed at ≥ `finality_depth` (node config, default 15 + blocks ≈ 5 minutes on Cardano); treat the settlement as settled only now; + - `SETTLING` — an attempt is in flight (or crashed mid-flight; the node + will not auto-retry an ambiguous attempt). +- **Redelivery is the polling mechanism.** Re-publishing the same instruction + (same key) returns the current recorded state — this is how OBP-API + observes the `SUBMITTED → FINAL` transition without a new message type, and + it composes with the transactional-outbox redelivery in §5.3. Keep + redelivering until `status = FINAL` (then flip the OBP-side settlement + state) or a terminal `OBP-BANK-NODE-SETTLEMENT-FAILED`. +- A failure reply (`SETTLEMENT-FAILED`) may be transient: if the underlying + cause provably never reached the chain, the node allows the retry on the + next redelivery; ambiguous failures (transport loss around broadcast) stick + and repeat the recorded error until reconciled by ops. + +### 4.5 `obp_status_update` / `obp_netting_snapshot` + +Lighter; the Bank Node records and ACKs. `status_update` = +`{ transaction_request_id, status }`. `netting_snapshot` carries `snapshot_id` (+ +whatever snapshot detail). Optional for the first slice. + +## 5. The OBP-API build (in order) + +### 5.0 Rename the TR types (decided 2026-07-18) + +`OPEN_CORRIDOR` → `OPEN_CORRIDOR_PROMISE`; add `OPEN_CORRIDOR_SETTLEMENT` for +the settle leg (§5.3's TR B). Rationale: `PROMISE`/`SETTLEMENT` name +mechanisms, the `OPEN_CORRIDOR_` prefix binds them to the scheme's rules +(`OPEN_CORRIDOR_SIMPLE_NETTING.md` §0). Bare names were rejected: bare +`SETTLEMENT` collides with the unrelated v7 market-trading settlement +endpoints, and a bare generic `PROMISE` would force any future +deferred-settlement product to inherit the Travel-Rule body shape and share +per-type props. The scoped names converge with the Bank Node's +`LEDGER_DESIGN.md` vocabulary (`OPEN_CORRIDOR_PROMISE`). + +Rename inventory (OBP-API): +- enum value at `Enumerations.scala:130` (+ add the `OPEN_CORRIDOR_SETTLEMENT` value); +- route literal + ResourceDoc URL template in `Http4s700.scala` + (`.../transaction-request-types/OPEN_CORRIDOR_PROMISE/transaction-requests`); +- the `OPEN_CORRIDOR` cases in `LocalMappedConnector.scala` (`:5267`) and + `MappedTransactionRequestProvider.scala` (`:107/:171/:275/:347`); +- prop name `transactionRequests_challenge_threshold_OPEN_CORRIDOR` → + `..._OPEN_CORRIDOR_PROMISE`; +- `OpenCorridorProcessor.scala:42`'s type constant; +- **add both new names to `literalAllCapsSegments` in `Http4sSupport.scala`** + so the ResourceDoc matcher treats them as literal path segments, not + wildcards. (Latent quirk found 2026-07-18: `OPEN_CORRIDOR` itself is + missing from that set today — the rename must not reproduce that.) +- tests + any dev/test data rows carrying the old type string. + +Bank Node side: its submit-TR client URL and any internal type strings +(rename agreed 2026-07-18). The §4 wire contract carries no TR type, so it is +untouched. + +### 5.1 Record the promise + accept the report-back + +> **BUILT (2026-07-28, branch `open-corridor-salt-relay`).** Endpoint +> `POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/open-corridor/promise`, +> body `{ tx_hash, blockchain, commitment, salt, preimage }` — **field renamed +> from `cardano_tx_hash` to `tx_hash`** (2026-07-28): the chain is already +> identified by `blockchain`, so the hash field is chain-neutral. Build the +> Bank Node report-back client against `tx_hash`. Gated by new bank-level +> role `CanAttachOpenCorridorPromise`. Evidence lands as TR attributes +> (`open_corridor_tx_hash` / `_blockchain` / `_commitment` / `_salt` / +> `_preimage` + `open_corridor_promise_reported_by` / `_reported_at` audit +> side-car, `OpenCorridorProcessor.attachPromiseEvidence`). Idempotent: +> identical re-post returns the stored record; differing evidence refused +> (OBP-40053, append-once); TR row-locked against concurrent double-attach. +> Requires TR type `OPEN_CORRIDOR_PROMISE` (OBP-40051) at status `PENDING` +> (OBP-40052). The hold-at-PENDING routing (§8.4) is also built: `getStatus` +> holds below-threshold promises at `PENDING`, challenge-answer lands at +> `PENDING` (never posts), the pending-TR scheduler skips `OPEN_CORRIDOR*` +> types, and the type's default challenge threshold is effectively infinite. + +After Bank A's node writes the Promise to Cardano, it must report the references +back so OBP-API can relay the salt. Expose a way to attach to the existing +TransactionRequest: + +- **New inbound endpoint** (v7.0.0, http4s — follow `CLAUDE.md` ResourceDoc + rules), e.g. + `POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/open-corridor/promise` + with body `{ tx_hash, blockchain, commitment, salt, preimage }`. +- **Persist on the TransactionRequest** (revised 2026-07-18, per + `OPEN_CORRIDOR_SIMPLE_NETTING.md`): the promise **is** the TR held at + `PENDING` — no Transaction is posted until the settle step, so there is no + `MappedTransaction` row to carry a `PROMISED` status. Store + `cardano_tx_hash` / `blockchain` / `commitment` / `salt` / `preimage` as + **Transaction Request attributes**; the TR `status` stays `PENDING` + (existing enum value, no schema change). The heavier options this doc + previously listed (`MappedTransaction.status = PROMISED` + transaction + attributes, or the OC tables from `OBP_API_CHANGES.md` §1) remain the + productionization path only if the snapshot-as-audit-object requirement + returns. +- Audit-log the report-back (who attached which promise evidence, when). +- **The Bank Node's client half of this call does not exist yet either** — its + dispatcher today stops at `PROMISE_WRITTEN` and reports nothing back + (`OBP-Bank-Node/crates/obp-bank-node/src/dispatcher.rs`). Coordinate: this + endpoint's shape is the contract for that new outbox step. + +> Why report-back rather than OBP-API reconstructing the preimage: the preimage +> format is a Bank-Node-internal detail (it hashes `{tx_request_id, originating +> bank/account, instruction}`). Relaying keeps OBP-API decoupled from that format +> and keeps the commitment exactly reproducible. + +### 5.2 Server-initiated publish capability + +Add a publish-and-await-reply to the bank's vhost. **Reuse the existing RPC +shape** (`RabbitMQUtils.sendRequestUndGetResponseFromRabbitMQ`, `:90`) — it +already does publish-to-`obp_rpc_queue` + await-on-`replyTo` + correlate. The new +parts: + +- **Target the bank's vhost — per-bank connections are required, not + optional** (revised 2026-07-18). Even one corridor needs **two** vhosts: + `obp_credit_notification` goes to the creditor bank's vhost and + `obp_settlement_instruction` to the debtor's. Build a per-bank broker + registry (bank_id → host/port/vhost/credentials, a small table populated at + onboarding) and connection handling keyed by bank_id — the + `RabbitMQConnectionPool` refactor of `OBP_API_CHANGES.md` §4a, or an + equivalent that manages one connection per bank with reconnect/backoff. + A hardcoded single-bank connection cannot deliver the flow at all. +- **New `MessageId`s + DTOs** (§5.4). +- Map the bank's reply envelope (§4.2): success on `errorCode == ""`, otherwise + surface the `errorCode` to the caller / mark the TR `EXCEPTION`. + +### 5.3 The trigger: the admin settle-pair endpoint (revised 2026-07-18) + +The trigger is the settle-pair endpoint from `OPEN_CORRIDOR_SIMPLE_NETTING.md` +§6 — **not** a per-promise "settle now" action. With one pending promise it +degenerates to exactly the old behaviour, so nothing is lost and real netting +(N pending promises → one net settlement) is gained: + +- `POST /obp/v7.0.0/open-corridor/settle` (shape to taste, e.g. body + `{ bank_id_a, bank_id_b, currency }`), gated by a **system role** (new + ApiRole, e.g. `CanSettleOpenCorridor`). +- It, atomically: + 1. queries the `PENDING` `OPEN_CORRIDOR_PROMISE` TRs for the pair+currency, + both directions; computes `net = SUM(A→B) − SUM(B→A)`; debtor = the side + that owes, creditor = the other; + 2. mints one internal `OPEN_CORRIDOR_SETTLEMENT` TransactionRequest + ("TR B") between the pair's settlement accounts and executes it, posting + **one Transaction** for `abs(net)` (revised 2026-07-18 — TR-first, not a + direct connector-level posting; the Transaction's id lands in TR B's + `transaction_ids` causally, as normal, and TR B doubles as the + settle-event audit object); + 3. writes that Transaction's id into each covered promise TR's + `settled_by_transaction_ids` attribute (revised 2026-07-18 — NOT into + `transaction_ids`, which keeps its causal meaning and stays empty on + promises; see `OPEN_CORRIDOR_SIMPLE_NETTING.md` §4a) and sets them + `COMPLETED`; + 4. publishes `obp_credit_notification` to **each creditor-side beneficiary + bank** with the relayed evidence triplet (one per covered inbound TR), + and `obp_settlement_instruction` (the **net** amount) to the **debtor**'s + vhost; + 5. records the replies (per §4.2; a non-empty `errorCode` must not be + swallowed). +- **Idempotency:** mint a stable settlement id when the settle begins and use + it as `settlement_id` *and* `idempotency_key`; a re-trigger for a pair with + no `PENDING` TRs is a no-op. Steps 1–3 run in one DB transaction so a + concurrent double-trigger cannot settle the same promises twice. +- **The transactional outbox is required** (revised 2026-07-18): steps 1–3 + commit money movement in the DB, and the publishes in step 4 must survive a + crash between commit and publish. Write the outbound messages into an + outbox table in the *same* DB transaction as steps 1–3 + (`OBP_API_CHANGES.md` §9), with a relay that publishes and records the + §4.2 replies. Publish-after-commit without the outbox loses + credit notifications and settlement instructions on a crash — with real + money that is not acceptable. The Bank Node side is idempotent-friendly + (`idempotency_key` in metadata, evidence upserts), so redelivery is safe. +- The **scheduled netting engine** (cycle-based settling) remains future work + by policy choice — settle-on-demand is a legitimate production mode, not a + shortcut. + +### 5.4 DTOs + MessageDocs + +- Add `OutBoundCreditNotification` / `OutBoundSettlementInstruction` (and the + `InBound…` reply shapes matching §4.2) under + `obp-commons/src/main/scala/com/openbankproject/commons/dto/`, mirroring the + existing `OutBoundXxx` / `InBoundXxx` naming. +- Add a `messageDocs +=` entry per new message in + `RabbitMQConnector_vOct2024.scala` (format fixed by existing entries; + `OBP_API_CHANGES.md` §10). **These lock the wire format** — match §4 exactly. + +## 6. Configuration + +- `open_corridor_enabled = false` by default (gates the new endpoints). +- Each onboarded bank's broker coords (`host/port/vhost/username/password`) — prop or a + small table during onboarding. +- The bank's Cardano settlement (creditor) address, so the + `settlement_instruction` can be addressed (or carry it on the counterparty). + +## 7. Testing (robustness rule) + +- Unit: the OC status/state-machine transitions; the report-back persistence. +- Connector: publish/await against an in-memory or embedded broker + (`EmbeddedRabbitMQ.scala` exists under test) — assert the published body + matches §4 byte-for-field and the reply envelope is parsed. +- Integration: report-back → admin settle → assert both messages published with + the correct `messageId` and that the evidence triplet is relayed unchanged. +- Per `CLAUDE.md`: comprehensive, not happy-path-only. + +## 8. Open decisions for the implementer + +1. ~~**Promise storage**~~ — **decided 2026-07-18:** Transaction Request + attributes on the `PENDING` TR (see §5.1). No new columns, no + `MappedTransaction.status` reuse. +2. ~~**Multi-tenant broker**~~ — **decided 2026-07-18:** per-bank connection + registry + connection handling keyed by bank_id (see §5.2). A single-bank + connection cannot even serve one corridor (two vhosts are involved). +3. ~~**Trigger**~~ — **decided 2026-07-18:** the admin settle-pair endpoint + (§5.3); the scheduled netting actor is productionization. +4. ~~**Challenge step / where hold-at-`PENDING` lands**~~ — **decided + 2026-07-18.** Mechanics first: `createTransactionRequestv400` is + threshold-gated per type + (`transactionRequests_challenge_threshold_OPEN_CORRIDOR`), so there are + **two** auto-complete landing sites — below threshold the TR posts + immediately in the create path (`getStatus` → `COMPLETED`, + `LocalMappedConnector.scala:4660`), at/above it the TR is `INITIATED` + + challenge and posts in the answer-challenge flow. **Decision:** route + `OPEN_CORRIDOR` to `PENDING` in **both** branches (never post at create or + at challenge-answer). Initially set the threshold effectively infinite, + so no challenge fires — corridor traffic is M2M (OAuth2 + client-credentials + pinned cert; the customer's SCA already happened at + the originating bank's own channel, and the same M2M credential answering + its own challenge adds nothing). The threshold seam is deliberately kept: + lowering it later turns the challenge into an operational **four-eyes + control** — a bank-ops principal (not the creating client) answers the + challenge for high-value corridor payments. That is productionization, + with real limit controls (per-payment cap, daily corridor cap, + pre-funding/credit-line guard at promise time) the higher priority. +5. **Creditor Cardano address source** (for + `settlement_instruction.creditor_address`): a prop / onboarding table per + §6, vs. an attribute on the creditor bank's settlement account. Either + works; pick one and note it. +6. ~~**TR type naming**~~ — **decided 2026-07-18 (second pass):** + `OPEN_CORRIDOR_PROMISE` (renamed from `OPEN_CORRIDOR`) + + `OPEN_CORRIDOR_SETTLEMENT`; scoped names, not bare `PROMISE`/`SETTLEMENT` + (see §5.0 for rationale and rename inventory, + `OPEN_CORRIDOR_SIMPLE_NETTING.md` §0 for the scheme definition). +7. ~~**Settlement posting shape**~~ — **decided 2026-07-18 (second pass):** + via an internal `OPEN_CORRIDOR_SETTLEMENT` TransactionRequest ("TR B"), + not a direct connector-level posting (§5.3 step 2). Keeps the TR-first + convention and gives the settle event an audit object. +8. ~~**Discharge linkage**~~ — **decided 2026-07-18 (second pass):** a + `settled_by_transaction_ids` Transaction Request attribute on each covered + promise TR; `transaction_ids` keeps its causal meaning everywhere (empty + on promises, populated on TR B). See §5.3 step 3 and + `OPEN_CORRIDOR_SIMPLE_NETTING.md` §4a. +9. **Net-equals-zero settle** — when the pair's flows offset exactly, there + is no amount to post. Decide: complete the covered promises with no + Transaction (does TR B still get minted, completing with empty + `transaction_ids`?), or post a zero-amount marker Transaction to preserve + the linkage. Also decide what `obp_settlement_instruction` looks like in + this case (probably: none is sent — nothing moves — but the + `obp_credit_notification`s must still go out). Undecided. + +## 9. References + +- **Wire contract source of truth:** + `OBP-Bank-Node/crates/obp-bank-node/src/interface_c/{types.rs,router.rs}` + — now also proven by the transport integration test + (`interface_c/transport_tests.rs`, runs against a real broker). +- **The netting model this plan now follows:** + `OPEN_CORRIDOR_SIMPLE_NETTING.md` (this repo; a copy lives in + `OBP-Bank-Node/WIP/` — re-copy it there after the 2026-07-18 naming/linkage + revisions). +- **Full double-entry netting design (productionization):** + `OBP-Bank-Node/WIP/OBP_API_CHANGES.md`, `OBP-Bank-Node/DOCS/LEDGER_DESIGN.md` + (Bank Node repo docs were re-foldered 2026-07-17 into `WIP/` and `DOCS/`). +- **Why the salt must reach Bank B (the legal/evidence model):** + `OBP-Bank-Node/DOCS/how_hashes_would_be_used_by_lawyers_for_bank_b.md` +- **OBP-API conventions:** `CLAUDE.md` (http4s ResourceDoc/MessageDoc, connector + pattern), `OBP_API_CHANGES.md` §10 (MessageDocs). +- **Existing OC code:** `Http4s700.scala:3183`, + `bankconnectors/opencorridor/OpenCorridorProcessor.scala`, + `bankconnectors/rabbitmq/RabbitMQUtils.scala:90`. diff --git a/OPEN_CORRIDOR_SIMPLE_NETTING.md b/OPEN_CORRIDOR_SIMPLE_NETTING.md new file mode 100644 index 0000000000..a5e64c84ab --- /dev/null +++ b/OPEN_CORRIDOR_SIMPLE_NETTING.md @@ -0,0 +1,293 @@ +# Open Corridor — Simplest Bilateral Netting (on-demand, on the existing OBP transaction model) + +**Decision:** the simplest implementation that is *genuinely netting* — **bilateral, +settle-on-demand** — built on OBP's existing Transaction Request / Transaction model. +No snapshot table, no scheduler, no multilateral, no settlement policy entity, no FX. + +This is a design note, not a wire contract. The locked RabbitMQ contract lives in +`OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md`; the full ledger design lives in the +Bank Node repo's `LEDGER_DESIGN.md`. This doc is the deliberately-minimal slice. + +> **Revised 2026-07-18 (naming + settlement linkage).** Three decisions taken +> after this note was first written, folded in below: +> 1. **TR types renamed**: the promise TR type is `OPEN_CORRIDOR_PROMISE` +> (was `OPEN_CORRIDOR`); the settlement leg gets its own new type +> `OPEN_CORRIDOR_SETTLEMENT` (see §0 and §4a). +> 2. **The settlement is itself a TransactionRequest** ("TR B"): the settle +> step mints one `OPEN_CORRIDOR_SETTLEMENT` TR whose execution posts the +> single net Transaction — keeping OBP's TR-first convention (§4a). +> 3. **Discharge linkage gets its own field**: the net Transaction's id is +> recorded on each covered promise TR as `settled_by_transaction_ids` +> (a Transaction Request attribute), NOT written into `transaction_ids`. +> `transaction_ids` keeps its original causal meaning ("Transactions this +> TR produced") and therefore stays empty on promise TRs forever (§4a). + +--- + +## 0. What "Open Corridor" is (definition, 2026-07-18) + +The `OPEN_CORRIDOR_` prefix on the TR types is earned by the scheme having +real content — `PROMISE`/`SETTLEMENT` name *mechanisms*; **Open Corridor names +the scheme whose rules those mechanisms obey** (the way `SEPA` binds a TR to +that scheme's rules): + +**Open Corridor** is a scheme for direct bank-to-bank cross-border payment +corridors, in which: + +1. **Payments carry originator identity (Travel Rule).** Every payment + carries FATF Recommendation 16 originator information (name, address, + account routing) end-to-end, persisted with the payment record — + compliance is structural, not bolted on. +2. **Payment and settlement are separated: promise now, settle later.** A + payment is first recorded as a *promise* — a commitment by the + originating bank to pay — which accumulates rather than moving money + immediately. +3. **Settlement is bilateral and netted.** Outstanding promises between a + pair of banks are offset — `SUM(A→B) − SUM(B→A)` — and the *difference* + is settled in a single transfer. N promises collapse into one settlement. +4. **Promises are independently verifiable.** Each promise is anchored by a + salt-based commit–reveal hash written to a public blockchain by the + originating bank. The beneficiary bank receives the salt and preimage, + recomputes the hash, and verifies the commitment on-chain — proof that + does not depend on the originating bank's later cooperation. +5. **Banks keep their keys.** Each participating bank runs its own Bank + Node, which holds the bank's keys and performs all blockchain writes + (promise commitments, settlement transfers). OBP-API records, nets, and + messages — it never signs or moves funds on a bank's behalf. +6. **Settlement rides a public rail.** The net obligation is settled on a + public blockchain (currently Cardano), not through correspondent-banking + intermediaries. + +**"Open"** (confirmed 2026-07-18): any pair of banks can form a corridor +directly, over open-source software and a public settlement rail, without +correspondent intermediaries or a proprietary network operator. + +When the netting build lands, this definition becomes a Glossary entry +(`Glossary.scala`), referenced from both TR-type endpoint descriptions. + +--- + +## 1. What netting is (and isn't) + +Netting is the **offsetting**. Its irreducible core is three things, none droppable: + +1. **Accumulate** — promises pile up instead of settling immediately. +2. **Sum** — add up what each side owes the other. +3. **Offset** — subtract the two directions, settle the difference. + +"Net of one payment" is just the payment — that's deferred settlement, not netting. +The feature is the `SUM ... GROUP BY` and the subtraction in the settle step. + +**Worked example (KES):** +``` +A → B 1000 +A → B 2000 +B → A 500 +───────────── +net = (1000 + 2000) − 500 = 2500 → A owes B 2500 KES +``` +Three promises (3500 KES gross movement) collapse into **one** 2500 KES settlement. +That compression is the entire point. + +--- + +## 2. Scope decisions ("the fancy table") + +| # | Fancy thing | Decision | +|---|---|---| +| 1 | Snapshot batches as a DB entity | **Drop** — "all PENDING promises for this pair right now" is the implicit batch | +| 2 | Netting engine / scheduler | **Drop** — manual admin trigger, not time/volume cycles | +| 3 | Multilateral netting | **Drop** — bilateral only (net two banks at a time); scales by repetition, plugs in later if ever needed | +| 4 | Settlement policy entity | **Drop** — hardcode bilateral + manual | +| 5 | Net-positions endpoint | **Drop** — convenience only | +| 6 | FX conversion | **Drop** — settle in same currency/asset | + +**Kept (the irreducible core):** accumulate promises, plus one admin "settle this pair" +endpoint that does `SUM(A→B) − SUM(B→A)`, settles the difference, and marks those +promises settled. + +### Notes on the dropped rows + +- **#1 Snapshot.** A snapshot is a DB row drawing a hard line around a group of + promises being netted together (`snapshot_id`, status `OPEN→CLOSED→SETTLING→SETTLED`, + every promise stamped with its `snapshot_id`). You need it when netting runs on a + **cycle** — the snapshot freezes "which promises arrived in this window." Settling + **on demand** instead, the batch boundary is just *whatever is PENDING for this pair + at the moment the admin clicks settle* — defined implicitly by the query. The + trade-off accepted: you lose the audit object "batch #47 settled these 12 promises as + one event." For a PoC, the per-promise `PENDING → COMPLETED` transition is enough trail. + +- **#3 Multilateral.** Bilateral nets each *pair* independently; multilateral nets each + bank against the *whole group* through a central pool. Multilateral is more + compressive (a 3-bank cycle A→B→C→A can net to zero) but needs a central counterparty, + synchronized all-banks-at-once cycles (which drags #1 and #2 back in), and harder + failure handling. **Bilateral is not a dead end at scale:** with N banks you do the + same dead-simple pair calculation more times (up to N×(N−1)/2 trading pairs), each one + independent. It scales by *repetition of a simple thing*. Multilateral plugs in later + by changing only the settle step (sum-by-pair → sum-by-bank-against-pool) without + touching how promises are recorded. + +--- + +## 3. Yes — this fits the existing OBP transaction model + +OBP already distinguishes the two halves of netting: + +- **`TransactionRequest`** = the *intent / instruction* to pay. Has a `status` + lifecycle; does **not** move balance until it completes. → this is the **promise**. +- **`Transaction`** = a *posted* double-entry ledger record that moves balance. + → this is the **settlement**. + +That is exactly the promise/settlement split, expressed in primitives OBP already has. +We do **not** need the Bank Node `LEDGER_DESIGN.md` approach of modelling the promise as +a non-posting `Transaction` with `transaction_kind = OPEN_CORRIDOR_PROMISE` (which needs +a new column and special non-posting transactions). Using a Transaction Request for the +promise is more natural to OBP and less invasive. + +### Mapping (verified against the model) + +| Netting concept | OBP field | +|---|---| +| Promise (accumulating IOU) | an `OPEN_CORRIDOR_PROMISE` **TransactionRequest** (type renamed 2026-07-18; was `OPEN_CORRIDOR`) — already created by `createTransactionRequestOpenCorridor` | +| "still owed, not settled" | `TransactionRequest.status` held at **`PENDING`** (`Enumerations.scala:333`) | +| who owes whom | `from` (originating bank/account) + `body`'s `to` counterparty (resolves to payee bank) | +| amount / currency | `body.value` | +| "settled by settlement X" | write the net `Transaction`'s id into the promise TR's **`settled_by_transaction_ids`** attribute; set status **`COMPLETED`** (revised 2026-07-18 — `transaction_ids` is NOT used for this; see §4a) | + +So the `PROMISED → SETTLED` status is **not a new field** — it's the existing TR status +(`PENDING → COMPLETED`). No schema change on the promise side. + +`TransactionRequest` already carries `status: String`, `transaction_ids: String`, +`from: TransactionRequestAccount`, `body`, and `originator` (see +`obp-commons/.../model/CommonModel.scala`). +`TransactionRequestStatus` enum values: `INITIATED, PENDING, NEXT_CHALLENGE_PENDING, +FAILED, COMPLETED, FORWARDED, REJECTED, CANCELLED, CANCELLATION_PENDING`. + +--- + +## 4. The flow in OBP terms + +``` +1. createTransactionRequestOpenCorridor → creates the promise TR (type + OPEN_CORRIDOR_PROMISE), leave it at + PENDING (do NOT auto-complete into + a Transaction) + +2. promises accumulate as PENDING OPEN_CORRIDOR_PROMISE TransactionRequests + +3. admin "settle pair (A, B), currency C": + net = SUM(value of PENDING A→B TRs) − SUM(value of PENDING B→A TRs) + debtor = A if net > 0 else B ; creditor = the other + mint ONE OPEN_CORRIDOR_SETTLEMENT TransactionRequest ("TR B") between + A's and B's settlement accounts; its execution posts ONE Transaction + for abs(net) + write that Transaction's id into settled_by_transaction_ids of every + covered promise TR + set every covered promise TR status = COMPLETED +``` + +N pending promise TRs collapse into **one** posted Transaction (via one +settlement TR). That N→1 *is* the netting, and it fits cleanly because OBP +never required 1 TR = 1 Transaction. + +### 4a. The settlement TR and the two linkage fields (decided 2026-07-18) + +**The settlement is itself a TransactionRequest.** The settle step does not +post a Transaction directly at connector level; it mints one internal +`OPEN_CORRIDOR_SETTLEMENT` TR ("TR B") between the pair's settlement accounts, +and TR B's execution posts the net Transaction. Rationale: OBP's convention is +TR-first — a posted Transaction always has an originating TR. TR B is also the +natural audit object for the settle event, which quietly recovers most of the +"batch settled as one event" trail dropped with the snapshot table (§2 row 1). + +**Two linkage fields, two meanings.** `transaction_ids` keeps its original, +causal meaning everywhere: "the Transactions this TR *produced*". A promise TR +never produces a Transaction, so its `transaction_ids` stays empty forever; +TR B's `transaction_ids` carries the net Transaction, causally, as normal. +The *discharge* relationship — "the Transaction that *settled* me" — is +recorded on each covered promise TR in a separate field: +**`settled_by_transaction_ids`** (plural to mirror `transaction_ids`' +comma-separated shape and leave headroom for partial/multi-leg settlement). +Stored as a **Transaction Request attribute** (the same mechanism the promise +evidence uses, publish plan §5.1), so the promise side remains +schema-change-free; promote it to a real column only if it later needs +first-class querying. + +Note the discharge link is *not* representational: a B→A 500 promise can be +discharged by a 2500 A→B net Transaction — opposite direction, different +amount, between settlement accounts rather than the promise's own accounts. +Reconciliation logic must never assume the linked Transaction matches the +promise TR's body; document this loudly on the endpoints/Glossary when it +lands. + +**TR type names.** `OPEN_CORRIDOR_PROMISE` (renamed from `OPEN_CORRIDOR`) and +the new `OPEN_CORRIDOR_SETTLEMENT`. `PROMISE`/`SETTLEMENT` name mechanisms; +the `OPEN_CORRIDOR_` prefix binds them to the scheme's rules (§0) — the +promise must carry the originator block and commit–reveal evidence; the +settlement is the netted discharge over the corridor rail. Bare names were +rejected: a TR type is product-scoped (it selects the body shape and keys +per-type props like the challenge threshold), and bare `SETTLEMENT` collides +with the unrelated v7 market-trading settlement endpoints. The scoped names +also converge with the Bank Node's `LEDGER_DESIGN.md` vocabulary. Rename +inventory: publish plan §5.0. + +--- + +## 5. The one behaviour change required + +Today an `OPEN_CORRIDOR_PROMISE` request (pre-rename: `OPEN_CORRIDOR`) is "SIMPLE + a mandatory originator block," and SIMPLE +**completes immediately** — it posts a Transaction and moves money per request. For +netting it must instead **stop at `PENDING`** and post nothing until the settle step. + +**Where that change lands (verified against the code; decided 2026-07-18):** +`createTransactionRequestv400` is threshold-gated per type +(`transactionRequests_challenge_threshold_OPEN_CORRIDOR`), so auto-complete +has **two** landing sites: below the threshold the TR posts immediately in +the create path (`getStatus` → `COMPLETED`); at/above it the TR is +`INITIATED` + challenge and posts in the answer-challenge flow. The netting +change routes `OPEN_CORRIDOR_PROMISE` to `PENDING` in **both** branches. For the PoC +the threshold is set effectively infinite (no challenge fires — the corridor +hop is M2M and the customer's SCA already happened at the originating bank); +the seam is kept so a finite threshold can later make the challenge an +ops-desk four-eyes control for high-value payments. Details: +`OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md` §8.4. + +--- + +## 6. Build checklist + +**Already exists:** +- the `TransactionRequest` model, `PENDING`/`COMPLETED` statuses, `transaction_ids` linkage +- the `createTransactionRequestOpenCorridor` endpoint (`Http4s700.scala:3183`) +- settlement accounts (`OBP-INCOMING-SETTLEMENT-ACCOUNT` / + `OBP-OUTGOING-SETTLEMENT-ACCOUNT`, per `Glossary.scala`) +- the posted-Transaction machinery + +**New work (the whole build — ordering per +`OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md` §5, which this note now governs):** +0. Rename the TR type `OPEN_CORRIDOR` → `OPEN_CORRIDOR_PROMISE` and add the + `OPEN_CORRIDOR_SETTLEMENT` type (rename inventory: publish plan §5.0). +1. Promise report-back endpoint + TR-attribute storage (publish plan §5.1); + the promise state lives on the `PENDING` TR, never on a Transaction. +2. Hold the `OPEN_CORRIDOR_PROMISE` TR at `PENDING` (stop the auto-complete) + — in both landing sites: the below-threshold create path and the + answer-challenge flow (see §5 above). +3. Publish-and-await-reply to the bank's vhost (publish plan §5.2). +4. One admin "settle pair" endpoint (publish plan §5.3): query the PENDING + pair, compute `SUM(A→B) − SUM(B→A)`, mint the `OPEN_CORRIDOR_SETTLEMENT` + TR ("TR B") whose execution posts the single net `Transaction` against + the settlement accounts, record that Transaction's id in each covered + promise TR's `settled_by_transaction_ids` attribute, mark them + `COMPLETED` — steps in one DB transaction, with a stable settlement id as + the idempotency key — then publish the RabbitMQ messages (credit + notifications to beneficiaries, the net settlement instruction to the + debtor). + +--- + +## 7. The seam for later + +If bilateral is ever outgrown, multilateral plugs into the settle step only +(sum-by-pair → sum-by-bank-against-pool). If a regulator needs the batch-as-one-event +audit object, add the snapshot table (#1) back and stamp each TR. Neither requires +reworking how promises are recorded — the PENDING TransactionRequest is the stable seam. diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 54bcc7ae48..594fd36d9e 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -135,6 +135,7 @@ import code.transactionRequestAttribute.TransactionRequestAttribute import code.transactionStatusScheduler.TransactionRequestStatusScheduler import code.transaction_types.MappedTransactionType import code.transactionattribute.MappedTransactionAttribute +import code.bankconnectors.opencorridor.OpenCorridorBankBroker import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons} import code.usercustomerlinks.MappedUserCustomerLink import code.customerlinks.CustomerLink @@ -988,6 +989,7 @@ object ToSchemify extends MdcLoggable { MappedCounterpartyWhereTag, MappedTransactionRequest, TransactionRequestAttribute, + OpenCorridorBankBroker, MappedMetric, MetricArchive, MetricsArchiveRun, diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index fad2f98b0d..64cad45705 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -207,6 +207,16 @@ object ApiRole extends MdcLoggable{ case class CanCreateAnyTransactionRequest(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateAnyTransactionRequest = CanCreateAnyTransactionRequest() + // Open Corridor: the bank's own Bank Node (M2M service user) reports Cardano promise + // evidence back to the PENDING OPEN_CORRIDOR_PROMISE Transaction Request. + case class CanAttachOpenCorridorPromise(requiresBankId: Boolean = true) extends ApiRole + lazy val canAttachOpenCorridorPromise = CanAttachOpenCorridorPromise() + + // Open Corridor: operator role for registering each onboarded bank's RabbitMQ broker + // coordinates (host/port/vhost/credentials) in the per-bank publish registry. + case class CanConfigureOpenCorridorBroker(requiresBankId: Boolean = false) extends ApiRole + lazy val canConfigureOpenCorridorBroker = CanConfigureOpenCorridorBroker() + case class CanAddSocialMediaHandle(requiresBankId: Boolean = true) extends ApiRole lazy val canAddSocialMediaHandle = CanAddSocialMediaHandle() diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 3747454e6d..c91f721891 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -886,6 +886,11 @@ object ErrorMessages { val InvalidOperationId = "OBP-40048: Invalid operation_id, please specify valid operation_id." val TransactionRequestLockFailed = "OBP-40050: Could not acquire a lock on the Transaction Request. Please try again." + val OpenCorridorPromiseTypeMismatch = "OBP-40051: The Transaction Request is not of type OPEN_CORRIDOR_PROMISE." + val OpenCorridorPromiseNotPending = "OBP-40052: The Open Corridor promise Transaction Request is not in PENDING status." + val OpenCorridorPromiseEvidenceConflict = "OBP-40053: Open Corridor promise evidence is already attached to this Transaction Request with different values. Evidence cannot be overwritten." + val OpenCorridorBankBrokerNotConfigured = "OBP-40054: No Open Corridor broker is configured for this bank. Register the bank's RabbitMQ coordinates first." + val OpenCorridorPublishFailed = "OBP-40055: Could not publish the Open Corridor message to the bank's broker or no reply arrived in time." // Exceptions (OBP-50XXX) val UnknownError = "OBP-50000: Unknown Error." val FutureTimeoutException = "OBP-50001: Future Timeout Exception." 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 a56894473b..a82de8c16b 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 @@ -8,7 +8,7 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} +import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureOpenCorridorBroker, canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.CommonsEmailWrapper import code.model.dataAccess.{AuthUser, MappedBank, ResourceUser} import code.consent.Consents @@ -3407,7 +3407,9 @@ object Http4s700 { "Create Transaction Request (OPEN_CORRIDOR_PROMISE)", """Initiate an OPEN_CORRIDOR_PROMISE Transaction Request — an Open Corridor Travel-Rule-friendly payment that carries FATF Recommendation 16 originator information about the actual payer. | - |Money-movement is identical to the SIMPLE transaction request type (same beneficiary routing fields). What's distinct: the `originator` block is mandatory and is persisted alongside the transaction request. The v7 response includes a populated originator block. + |The beneficiary routing fields are the same shape as the SIMPLE transaction request type, and the `originator` block is mandatory and persisted alongside the transaction request. The v7 response includes a populated originator block. + | + |An OPEN_CORRIDOR_PROMISE does not post a Transaction at create time: the request is held at status `PENDING` as a promise, accumulating for bilateral netting. The Open Corridor settle step later nets all pending promises between a bank pair and posts one net Transaction, at which point covered promises become `COMPLETED`. | |Authentication is Required.""".stripMargin, openCorridorBodyExample, @@ -3419,8 +3421,10 @@ object Http4s700 { account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f1" ), details = openCorridorBodyExample, - transaction_ids = List("902ba3bb-dedd-45e7-9319-2fd3f2cd98a1"), - status = "COMPLETED", + // Promises are held at PENDING with no posted Transaction: they accumulate for + // bilateral netting and the settle-pair step posts the net later. + transaction_ids = Nil, + status = "PENDING", start_date = code.api.util.APIUtil.DateWithDayExampleObject, end_date = code.api.util.APIUtil.DateWithDayExampleObject, challenges = Nil, @@ -3446,6 +3450,191 @@ object Http4s700 { http4sPartialFunction = Some(createTransactionRequestOpenCorridor) ) + // ── OPEN_CORRIDOR promise report-back (salt relay intake) ──────────────── + // After the bank's Bank Node writes the Promise commitment on-chain, it reports + // the tx hash and the commit–reveal evidence (commitment, salt, preimage) back + // here. OBP-API stores them as Transaction Request attributes on the PENDING + // promise TR and later relays the evidence to the beneficiary bank inside + // obp_credit_notification. The evidence is opaque to OBP-API. + val attachOpenCorridorPromise: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / "transaction-requests" / transactionRequestIdStr / "open-corridor" / "promise" if transactionRequestIdStr.nonEmpty => + EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory700.PostOpenCorridorPromiseJsonV700, JSONFactory700.OpenCorridorPromiseJsonV700](req) { (user, bank, body, cc) => + for { + account <- scala.concurrent.Future(cc.bankAccount.getOrElse(throw new RuntimeException(BankAccountNotFound))) + (promiseJson, _) <- code.bankconnectors.opencorridor.OpenCorridorProcessor.attachPromiseEvidence( + user, bank.bankId, account.accountId, + com.openbankproject.commons.model.TransactionRequestId(transactionRequestIdStr), body, Some(cc) + ) + } yield promiseJson + } + } + + val openCorridorPromiseBodyExample = JSONFactory700.PostOpenCorridorPromiseJsonV700( + tx_hash = "63eacfe3dbc133f922d461bd3e6488ce21d55f03c5131cd79c965fe2e7491642", + blockchain = "cardano", + commitment = "9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1", + salt = "5f4dcc3b5aa765d61d8327deb882cf99", + preimage = "{\"tx_request_id\":\"tr-abc-123\",\"instruction\":\"...\"}" + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(attachOpenCorridorPromise), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/open-corridor/promise", + "Attach Open Corridor Promise Evidence", + """Attach on-chain promise evidence to a PENDING OPEN_CORRIDOR_PROMISE Transaction Request. + | + |Called by the bank's own Bank Node (machine-to-machine) after it has written the Promise commitment to the blockchain. The body carries the transaction hash of the on-chain write plus the commit–reveal evidence: the `commitment` (the hash written on-chain), the `salt`, and the `preimage`. OBP-API stores these as Transaction Request attributes and later relays them to the beneficiary bank inside the `obp_credit_notification` message, enabling the beneficiary to verify `SHA-256(salt ‖ preimage)` against the on-chain commitment without the originating bank's cooperation. + | + |The evidence fields are opaque strings to OBP-API — they are stored and relayed verbatim, never parsed. + | + |This call is idempotent: re-posting identical evidence returns the stored record. Posting different evidence for a Transaction Request that already has evidence attached is refused — evidence is append-once and cannot be overwritten. + | + |Authentication is Required.""".stripMargin, + openCorridorPromiseBodyExample, + JSONFactory700.OpenCorridorPromiseJsonV700( + transaction_request_id = "4050046c-63b3-4868-8a22-14b4181d33a6", + transaction_request_status = "PENDING", + tx_hash = "63eacfe3dbc133f922d461bd3e6488ce21d55f03c5131cd79c965fe2e7491642", + blockchain = "cardano", + commitment = "9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1", + salt = "5f4dcc3b5aa765d61d8327deb882cf99", + preimage = "{\"tx_request_id\":\"tr-abc-123\",\"instruction\":\"...\"}", + reported_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + reported_at = "2026-07-28T10:00:00.000Z" + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, $BankAccountNotFound, + InvalidJsonFormat, InvalidJsonValue, InvalidTransactionRequestId, + OpenCorridorPromiseTypeMismatch, OpenCorridorPromiseNotPending, + OpenCorridorPromiseEvidenceConflict, TransactionRequestLockFailed, UnknownError), + apiTagTransactionRequest :: Nil, + Some(List(canAttachOpenCorridorPromise)), + http4sPartialFunction = Some(attachOpenCorridorPromise) + ) + + // ── OPEN_CORRIDOR per-bank broker registry (admin) ──────────────────────── + // Operator endpoints for the per-bank RabbitMQ publish registry: each onboarded + // bank's Bank Node consumes on its own vhost, so Interface C publishing needs + // the bank's broker coordinates. Passwords are write-only (never echoed). + + val setOpenCorridorBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "open-corridor" / "broker" => + EndpointHelpers.withUserAndBankAndBody[JSONFactory700.PostOpenCorridorBankBrokerJsonV700, JSONFactory700.OpenCorridorBankBrokerJsonV700](req) { (_, bank, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue host, virtual_host and username must be non-empty and port must be positive", cc = Some(cc)) { + body.host.trim.nonEmpty && body.virtual_host.trim.nonEmpty && body.username.trim.nonEmpty && body.port > 0 + } + broker <- scala.concurrent.Future { + code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( + bank.bankId.value, body.host, body.port, body.virtual_host, body.username, body.password, body.use_ssl + ) + } + } yield JSONFactory700.OpenCorridorBankBrokerJsonV700( + bank_id = broker.bankId, host = broker.host, port = broker.port, + virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl + ) + } + } + + val getOpenCorridorBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "open-corridor" / "broker" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + scala.concurrent.Future { + code.bankconnectors.opencorridor.OpenCorridorBankBroker.findByBankId(bank.bankId.value) match { + case net.liftweb.common.Full(broker) => + JSONFactory700.OpenCorridorBankBrokerJsonV700( + bank_id = broker.bankId, host = broker.host, port = broker.port, + virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl + ) + case _ => + throw new RuntimeException(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: ${bank.bankId.value}") + } + } + } + } + + val deleteOpenCorridorBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "open-corridor" / "broker" => + EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => + scala.concurrent.Future { + code.bankconnectors.opencorridor.OpenCorridorBankBroker.deleteByBankId(bank.bankId.value) + } + } + } + + val openCorridorBrokerBodyExample = JSONFactory700.PostOpenCorridorBankBrokerJsonV700( + host = "rabbitmq.bank.example.com", + port = 5672, + virtual_host = "/bank.gh.29.uk", + username = "obp-api", + password = "***", + use_ssl = false + ) + val openCorridorBrokerResponseExample = JSONFactory700.OpenCorridorBankBrokerJsonV700( + bank_id = "gh.29.uk", + host = "rabbitmq.bank.example.com", + port = 5672, + virtual_host = "/bank.gh.29.uk", + username = "obp-api", + use_ssl = false + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(setOpenCorridorBankBroker), + "PUT", + "/banks/BANK_ID/open-corridor/broker", + "Set Open Corridor Bank Broker", + """Register (or replace) the RabbitMQ broker coordinates for a bank in the Open Corridor per-bank publish registry. + | + |Each onboarded bank's Bank Node consumes Interface C messages on its own vhost with its own credentials; OBP-API publishes `obp_credit_notification` to the creditor bank's vhost and `obp_settlement_instruction` to the debtor bank's vhost using the coordinates registered here. One registration per bank (upsert semantics). + | + |The password is write-only and never returned by any endpoint. + | + |Authentication is Required.""".stripMargin, + openCorridorBrokerBodyExample, + openCorridorBrokerResponseExample, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, InvalidJsonFormat, InvalidJsonValue, UnknownError), + apiTagBank :: Nil, + Some(List(canConfigureOpenCorridorBroker)), + http4sPartialFunction = Some(setOpenCorridorBankBroker) + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getOpenCorridorBankBroker), + "GET", + "/banks/BANK_ID/open-corridor/broker", + "Get Open Corridor Bank Broker", + """Get the registered Open Corridor RabbitMQ broker coordinates for a bank (password omitted). + | + |Authentication is Required.""".stripMargin, + EmptyBody, + openCorridorBrokerResponseExample, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, OpenCorridorBankBrokerNotConfigured, UnknownError), + apiTagBank :: Nil, + Some(List(canConfigureOpenCorridorBroker)), + http4sPartialFunction = Some(getOpenCorridorBankBroker) + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteOpenCorridorBankBroker), + "DELETE", + "/banks/BANK_ID/open-corridor/broker", + "Delete Open Corridor Bank Broker", + """Remove a bank's Open Corridor RabbitMQ broker registration. Idempotent. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, UnknownError), + apiTagBank :: Nil, + Some(List(canConfigureOpenCorridorBroker)), + http4sPartialFunction = Some(deleteOpenCorridorBankBroker) + ) + // ── End OPEN_CORRIDOR_PROMISE ───────────────────────────────────────────── // ── BULK transaction request ────────────────────────────────────────────── 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 2b92ace4c8..66ebe67eca 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 @@ -1071,7 +1071,9 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { `type` = v4.`type`, from = v4.from, details = requestBody, - transaction_ids = v4.transaction_ids, + // The v4 factory emits List("") for a TR with no transactions; a held promise + // genuinely has none, so v7 emits a truly empty list instead. + transaction_ids = v4.transaction_ids.filter(_.trim.nonEmpty), status = v4.status, start_date = v4.start_date, end_date = v4.end_date, @@ -1081,6 +1083,57 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ) } + // ─── OPEN_CORRIDOR promise report-back (salt relay intake) ───────────────── + // + // After the Bank Node writes the Promise commitment to Cardano, it reports the + // on-chain references and the commit–reveal evidence back so OBP-API can relay + // the salt to the beneficiary bank in `obp_credit_notification`. The evidence + // fields are opaque to OBP-API: it stores and relays them, it never needs to + // parse the preimage. + + // `tx_hash` is deliberately chain-neutral: the chain is identified by `blockchain` + // (e.g. "cardano"), so the hash field must not bake a chain name in. + case class PostOpenCorridorPromiseJsonV700( + tx_hash: String, + blockchain: String, + commitment: String, + salt: String, + preimage: String + ) + + case class OpenCorridorPromiseJsonV700( + transaction_request_id: String, + transaction_request_status: String, + tx_hash: String, + blockchain: String, + commitment: String, + salt: String, + preimage: String, + reported_by_user_id: String, + reported_at: String + ) + + // ─── OPEN_CORRIDOR per-bank broker registry (admin) ──────────────────────── + + case class PostOpenCorridorBankBrokerJsonV700( + host: String, + port: Int, + virtual_host: String, + username: String, + password: String, + use_ssl: Boolean + ) + + // The password is write-only: never echoed on any response. + case class OpenCorridorBankBrokerJsonV700( + bank_id: String, + host: String, + port: Int, + virtual_host: String, + username: String, + use_ssl: Boolean + ) + // Build the originator block for a TR response. Returns None when there's no // explicit originator and no customer_account_link for the from-account — the // outer JSON wrapper emits `originator: null` in that case. diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 4d97456300..1137ba5926 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -173,7 +173,14 @@ object LocalMappedConnector extends Connector with MdcLoggable { username: String, callContext: Option[CallContext]): OBPReturnType[Box[AmountOfMoney]] = Future { val propertyName = "transactionRequests_challenge_threshold_" + transactionRequestType.toUpperCase - val threshold = BigDecimal(APIUtil.getPropsValue(propertyName, "1000")) + // OPEN_CORRIDOR_PROMISE traffic is M2M (OAuth2 client-credentials; the customer's SCA + // happened at the originating bank's own channel), so its default threshold is effectively + // infinite and no challenge fires. An operator can still set the prop explicitly to turn + // the challenge into a four-eyes control for high-value corridor payments. + val defaultThreshold = + if (transactionRequestType.equalsIgnoreCase(TransactionRequestTypes.OPEN_CORRIDOR_PROMISE.toString)) "999999999999" + else "1000" + val threshold = BigDecimal(APIUtil.getPropsValue(propertyName, defaultThreshold)) logger.debug(s"threshold is $threshold") val thresholdCurrency: String = APIUtil.getPropsValue("transactionRequests_challenge_currency", "EUR") @@ -4660,7 +4667,15 @@ object LocalMappedConnector extends Connector with MdcLoggable { // Set initial status override def getStatus(challengeThresholdAmount: BigDecimal, transactionRequestCommonBodyAmount: BigDecimal, transactionRequestType: TransactionRequestType, callContext: Option[CallContext]): OBPReturnType[Box[TransactionRequestStatus.Value]] = { Future(Full( - if (transactionRequestCommonBodyAmount < challengeThresholdAmount && transactionRequestType.value != REFUND.toString) { + // OPEN_CORRIDOR_PROMISE is held at PENDING: promises accumulate for bilateral netting + // and no Transaction may post at create time — the settle-pair step posts the net later + // (OPEN_CORRIDOR_SIMPLE_NETTING.md). At/above the challenge threshold the INITIATED + + // challenge seam still applies (four-eyes); the challenge answer also lands at PENDING + // (see createTransactionAfterChallengeV210). + if (transactionRequestType.value == TransactionRequestTypes.OPEN_CORRIDOR_PROMISE.toString + && transactionRequestCommonBodyAmount < challengeThresholdAmount) { + TransactionRequestStatus.PENDING + } else if (transactionRequestCommonBodyAmount < challengeThresholdAmount && transactionRequestType.value != REFUND.toString) { // For any connector != mapped we should probably assume that transaction_request_status_scheduler_delay will be > 0 // so that getTransactionRequestStatuses needs to be implemented for all connectors except mapped. // i.e. if we are certain that saveTransaction will be honored immediately by the backend, then transaction_request_status_scheduler_delay @@ -5115,6 +5130,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def createTransactionAfterChallengeV210(fromAccount: BankAccount, transactionRequest: TransactionRequest, callContext: Option[CallContext]): OBPReturnType[Box[TransactionRequest]] = { + // OPEN_CORRIDOR_PROMISE never posts at challenge-answer: a successfully answered challenge + // (four-eyes control) admits the promise into the corridor at PENDING, where it accumulates + // for bilateral netting. The settle-pair step posts the net Transaction later + // (OPEN_CORRIDOR_SIMPLE_NETTING.md). Posting here would move funds outside the netting model. + if (transactionRequest.`type` == TransactionRequestTypes.OPEN_CORRIDOR_PROMISE.toString) { + for { + _ <- NewStyle.function.saveTransactionRequestStatusImpl(transactionRequest.id, TransactionRequestStatus.PENDING.toString, callContext) + (heldTransactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(transactionRequest.id, callContext) + } yield { + (Full(heldTransactionRequest), callContext) + } + } else for { body <- Future(transactionRequest.body) @@ -5260,60 +5287,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } yield { (transactionId, callContext) } - // OPEN_CORRIDOR_PROMISE: money-movement is identical to SIMPLE today (same beneficiary - // routing shape). The originator block is persisted on the TR row by - // MappedTransactionRequestProvider and surfaced on v7 responses by - // JSONFactory700.buildTransactionRequestOriginatorJson — neither is needed here. - // Reuses the SIMPLE path via body.to_simple. - case OPEN_CORRIDOR_PROMISE => - for { - bodyToSimple <- NewStyle.function.tryons(s"$TransactionRequestDetailsExtractException It can not extract to $TransactionRequestBodyCounterpartyJSON", 400, callContext) { - body.to_simple.get - } - (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByRoutings( - bodyToSimple.otherBankRoutingScheme, - bodyToSimple.otherBankRoutingAddress, - bodyToSimple.otherBranchRoutingScheme, - bodyToSimple.otherBranchRoutingAddress, - bodyToSimple.otherAccountRoutingScheme, - bodyToSimple.otherAccountRoutingAddress, - bodyToSimple.otherAccountSecondaryRoutingScheme, - bodyToSimple.otherAccountSecondaryRoutingAddress, - callContext - ) - (toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, callContext) - counterpartyBody = TransactionRequestBodySimpleJsonV400( - to = PostSimpleCounterpartyJson400( - name = toCounterparty.name, - description = toCounterparty.description, - other_bank_routing_scheme = toCounterparty.otherBankRoutingScheme, - other_bank_routing_address = toCounterparty.otherBankRoutingAddress, - other_account_routing_scheme = toCounterparty.otherAccountRoutingScheme, - other_account_routing_address = toCounterparty.otherAccountRoutingAddress, - other_account_secondary_routing_scheme = toCounterparty.otherAccountSecondaryRoutingScheme, - other_account_secondary_routing_address = toCounterparty.otherAccountSecondaryRoutingAddress, - other_branch_routing_scheme = toCounterparty.otherBranchRoutingScheme, - other_branch_routing_address = toCounterparty.otherBranchRoutingAddress, - ), - value = AmountOfMoneyJsonV121(body.value.currency, body.value.amount), - description = body.description, - charge_policy = transactionRequest.charge_policy, - future_date = transactionRequest.future_date - ) - (transactionId, callContext) <- NewStyle.function.makePaymentv210( - fromAccount, - toAccount, - transactionRequest.id, - transactionRequestCommonBody = counterpartyBody, - BigDecimal(counterpartyBody.value.amount), - counterpartyBody.description, - TransactionRequestType(transactionRequestType), - transactionRequest.charge_policy, - callContext - ) - } yield { - (transactionId, callContext) - } + // OPEN_CORRIDOR_PROMISE is handled by the hold-at-PENDING branch at the top of this + // method and never reaches this match. // In the case of a REFUND (currently working only implemented for SEPA refund request) case REFUND => for { diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala new file mode 100644 index 0000000000..cdab7569da --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala @@ -0,0 +1,69 @@ +package code.bankconnectors.opencorridor + +import net.liftweb.common.Box +import net.liftweb.mapper._ + +/** + * Per-bank RabbitMQ broker coordinates for Open Corridor Interface C publishing. + * + * Each onboarded bank's Bank Node consumes on its OWN vhost (e.g. `/bank.ke.01.kcs`) + * with its own credentials — permission isolation is enforced at the broker level. + * Even a single corridor involves two vhosts (`obp_credit_notification` goes to the + * creditor bank's vhost, `obp_settlement_instruction` to the debtor's), so a single + * global broker connection cannot serve the flow; publishing is keyed by bank_id + * through this registry (populated at onboarding). + */ +class OpenCorridorBankBroker extends LongKeyedMapper[OpenCorridorBankBroker] with IdPK with CreatedUpdated { + def getSingleton = OpenCorridorBankBroker + + object BankId extends MappedString(this, 255) + object Host extends MappedString(this, 255) + object Port extends MappedInt(this) { + override def defaultValue = 5672 + } + object VirtualHost extends MappedString(this, 255) + object Username extends MappedString(this, 255) + object Password extends MappedString(this, 255) + object UseSsl extends MappedBoolean(this) { + override def defaultValue = false + } + + def bankId: String = BankId.get + def host: String = Host.get + def port: Int = Port.get + def virtualHost: String = VirtualHost.get + def username: String = Username.get + def password: String = Password.get + def useSsl: Boolean = UseSsl.get +} + +object OpenCorridorBankBroker extends OpenCorridorBankBroker with LongKeyedMetaMapper[OpenCorridorBankBroker] { + override def dbIndexes: List[BaseIndex[OpenCorridorBankBroker]] = UniqueIndex(BankId) :: super.dbIndexes + + def findByBankId(bankId: String): Box[OpenCorridorBankBroker] = + OpenCorridorBankBroker.find(By(OpenCorridorBankBroker.BankId, bankId)) + + /** Upsert the broker coordinates for a bank (one row per bank, enforced by the unique index). */ + def upsert( + bankId: String, + host: String, + port: Int, + virtualHost: String, + username: String, + password: String, + useSsl: Boolean + ): OpenCorridorBankBroker = { + val row = findByBankId(bankId).getOrElse(OpenCorridorBankBroker.create.BankId(bankId)) + row + .Host(host) + .Port(port) + .VirtualHost(virtualHost) + .Username(username) + .Password(password) + .UseSsl(useSsl) + .saveMe() + } + + def deleteByBankId(bankId: String): Boolean = + OpenCorridorBankBroker.bulkDelete_!!(By(OpenCorridorBankBroker.BankId, bankId)) +} diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala index 7fdca4920f..15598269d4 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala @@ -4,12 +4,15 @@ import org.json4s._ import code.api.ChargePolicy import code.api.util.APIUtil.getScaMethodAtInstance import code.api.util.ErrorMessages._ -import code.api.util.{CallContext, NewStyle} -import code.api.v7_0_0.JSONFactory700.TransactionRequestBodyOpenCorridorJsonV700 +import code.api.util.{APIUtil, CallContext, NewStyle} +import code.api.v7_0_0.JSONFactory700.{OpenCorridorPromiseJsonV700, PostOpenCorridorPromiseJsonV700, TransactionRequestBodyOpenCorridorJsonV700} import code.util.Helper import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ import com.openbankproject.commons.model.enums.ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE +import com.openbankproject.commons.model.enums.{TransactionRequestAttributeType, TransactionRequestStatus, TransactionRequestTypes} + +import java.util.Date import org.json4s.native.Serialization.write import org.json4s.NoTypeHints import org.json4s.native.Serialization @@ -101,4 +104,115 @@ object OpenCorridorProcessor { ) } yield (createdTransactionRequest, callContext) } + + // ─── Promise report-back (salt relay intake) ──────────────────────────────── + // + // Transaction Request attribute names carrying the on-chain promise evidence. + // The evidence triplet (commitment, salt, preimage) is opaque to OBP-API: it is + // stored verbatim and relayed to the beneficiary bank in obp_credit_notification, + // where the receiving Bank Node recomputes SHA-256(salt ‖ preimage) against the + // on-chain commitment and refuses to credit on a mismatch. + val PromiseAttributeTxHash = "open_corridor_tx_hash" + val PromiseAttributeBlockchain = "open_corridor_blockchain" + val PromiseAttributeCommitment = "open_corridor_commitment" + val PromiseAttributeSalt = "open_corridor_salt" + val PromiseAttributePreimage = "open_corridor_preimage" + // Audit side-car: who attached the evidence, and when. + val PromiseAttributeReportedBy = "open_corridor_promise_reported_by" + val PromiseAttributeReportedAt = "open_corridor_promise_reported_at" + + private val promiseEvidenceAttributeNames = Set( + PromiseAttributeTxHash, PromiseAttributeBlockchain, + PromiseAttributeCommitment, PromiseAttributeSalt, PromiseAttributePreimage + ) + + // Attach the Bank Node's on-chain promise evidence to a PENDING OPEN_CORRIDOR_PROMISE + // Transaction Request. Idempotent: re-reporting identical evidence returns the stored + // record; differing evidence is refused (OBP-40053) — evidence is append-once, so a + // recorded commitment can never be silently replaced after the fact. + def attachPromiseEvidence( + user: User, + bankId: BankId, + accountId: AccountId, + transactionRequestId: TransactionRequestId, + body: PostOpenCorridorPromiseJsonV700, + callContext: Option[CallContext] + ): Future[(OpenCorridorPromiseJsonV700, Option[CallContext])] = { + val submittedEvidence = Map( + PromiseAttributeTxHash -> body.tx_hash, + PromiseAttributeBlockchain -> body.blockchain, + PromiseAttributeCommitment -> body.commitment, + PromiseAttributeSalt -> body.salt, + PromiseAttributePreimage -> body.preimage + ) + for { + _ <- Helper.booleanToFuture( + s"$InvalidJsonValue tx_hash, blockchain, commitment, salt and preimage must all be non-empty", + cc = callContext) { + submittedEvidence.values.forall(_.trim.nonEmpty) + } + (tr, callContext) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + // Row lock for the rest of the request transaction: closes the race where two + // concurrent report-backs both see "no evidence yet" and both write. + _ <- Helper.booleanToFuture(TransactionRequestLockFailed, cc = callContext) { + code.bankconnectors.DoobieTransactionRequestQueries.lockTransactionRequest(transactionRequestId.value).isDefined + } + _ <- Helper.booleanToFuture( + s"$InvalidTransactionRequestId Transaction Request ${transactionRequestId.value} does not belong to BANK_ID ${bankId.value} and ACCOUNT_ID ${accountId.value}.", + cc = callContext) { + tr.from.bank_id == bankId.value && tr.from.account_id == accountId.value + } + _ <- Helper.booleanToFuture(s"$OpenCorridorPromiseTypeMismatch Current type: ${tr.`type`}.", cc = callContext) { + tr.`type` == TransactionRequestTypes.OPEN_CORRIDOR_PROMISE.toString + } + _ <- Helper.booleanToFuture(s"$OpenCorridorPromiseNotPending Current status: ${tr.status}.", cc = callContext) { + tr.status == TransactionRequestStatus.PENDING.toString + } + (existingAttributes, callContext) <- NewStyle.function.getTransactionRequestAttributes(bankId, transactionRequestId, callContext) + existingEvidence = existingAttributes + .filter(attribute => promiseEvidenceAttributeNames.contains(attribute.name)) + .map(attribute => attribute.name -> attribute.value).toMap + _ <- Helper.booleanToFuture(OpenCorridorPromiseEvidenceConflict, cc = callContext) { + existingEvidence.isEmpty || existingEvidence == submittedEvidence + } + (promiseJson, callContext) <- + if (existingEvidence.isEmpty) { + val reportedAt = APIUtil.DateWithMsFormat.format(new Date()) + val attributes = (submittedEvidence + + (PromiseAttributeReportedBy -> user.userId) + + (PromiseAttributeReportedAt -> reportedAt)) + .toList.map { case (name, value) => + TransactionRequestAttributeJsonV400(name, TransactionRequestAttributeType.STRING.toString, value) + } + NewStyle.function.createTransactionRequestAttributes( + bankId, transactionRequestId, attributes, isPersonal = false, callContext + ) map { case (_, callContext) => + (buildPromiseJson(tr, submittedEvidence, user.userId, reportedAt), callContext) + } + } else { + // Idempotent redelivery: identical evidence already attached — return the stored record. + val reportedBy = existingAttributes.find(_.name == PromiseAttributeReportedBy).map(_.value).getOrElse("") + val reportedAt = existingAttributes.find(_.name == PromiseAttributeReportedAt).map(_.value).getOrElse("") + Future.successful((buildPromiseJson(tr, existingEvidence, reportedBy, reportedAt), callContext)) + } + } yield (promiseJson, callContext) + } + + private def buildPromiseJson( + tr: TransactionRequest, + evidence: Map[String, String], + reportedByUserId: String, + reportedAt: String + ): OpenCorridorPromiseJsonV700 = + OpenCorridorPromiseJsonV700( + transaction_request_id = tr.id.value, + transaction_request_status = tr.status, + tx_hash = evidence.getOrElse(PromiseAttributeTxHash, ""), + blockchain = evidence.getOrElse(PromiseAttributeBlockchain, ""), + commitment = evidence.getOrElse(PromiseAttributeCommitment, ""), + salt = evidence.getOrElse(PromiseAttributeSalt, ""), + preimage = evidence.getOrElse(PromiseAttributePreimage, ""), + reported_by_user_id = reportedByUserId, + reported_at = reportedAt + ) } diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala new file mode 100644 index 0000000000..cafdf1d7f4 --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala @@ -0,0 +1,218 @@ +package code.bankconnectors.opencorridor + +import code.api.util.APIUtil +import code.api.util.ErrorMessages._ +import code.bankconnectors.rabbitmq.ResponseCallback +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.dto._ +import com.rabbitmq.client.AMQP.BasicProperties +import com.rabbitmq.client.{CancelCallback, Connection, ConnectionFactory} +import net.liftweb.common.{Box, Failure, Full} +import org.json4s.native.Serialization.write + +import java.util +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +/** + * Open Corridor Interface C publisher: server-initiated publish-and-await-reply to a + * BANK's own RabbitMQ vhost, keyed by bank_id through the OpenCorridorBankBroker + * registry. + * + * Structurally the same RPC shape as `RabbitMQUtils.sendRequestUndGetResponseFromRabbitMQ` + * (publish to `obp_rpc_queue`, await on a per-request `replyTo` queue, correlate by + * `correlationId`) but deliberately self-contained: `RabbitMQUtils`' object init hard-requires + * the global `rabbitmq_connector.*` props, which describe the OBP adapter broker, not the + * per-bank brokers this publisher talks to. The wire bodies are the flat lower_snake_case + * DTOs from `com.openbankproject.commons.dto` (locked contract — see the MessageDocs in + * `RabbitMQConnector_vOct2024`). + */ +object OpenCorridorPublisher extends MdcLoggable { + + private implicit val formats = code.api.util.CustomJsonFormats.nullTolerateFormats + + val RPC_QUEUE_NAME = "obp_rpc_queue" + val REPLY_QUEUE_NAME_PREFIX = "obp_reply_queue" + + /** Application-level cap on how long to wait for the bank's reply; kept below the + * 60s reply-queue TTL, mirroring the RabbitMQUtils setup. */ + val PUBLISH_RESPONSE_TIMEOUT_IN_MILLIS: Long = + APIUtil.getPropsAsLongValue("open_corridor.publish_response_timeout", 30000L) + + private val rpcQueueArgs = new util.HashMap[String, AnyRef]() + rpcQueueArgs.put("x-message-ttl", Integer.valueOf(60000)) + + private val replyQueueArgs = new util.HashMap[String, AnyRef]() + replyQueueArgs.put("x-expires", Integer.valueOf(60000)) + replyQueueArgs.put("x-message-ttl", Integer.valueOf(60000)) + + private val cancelCallback: CancelCallback = + (consumerTag: String) => logger.info(s"Open Corridor consumerTag($consumerTag) cancelled") + + /** One cached connection per bank, created lazily and replaced on disconnect. + * Channels are NOT cached — they are not thread-safe; one per publish, as in + * RabbitMQUtils. */ + private val connections = new ConcurrentHashMap[String, Connection]() + + private def connectionFor(broker: OpenCorridorBankBroker): Connection = { + connections.compute(broker.bankId, (_, existing) => { + if (existing != null && existing.isOpen) existing + else { + if (existing != null) { + try existing.close() catch { case _: Throwable => () } + } + val factory = new ConnectionFactory() + factory.setHost(broker.host) + factory.setPort(broker.port) + factory.setVirtualHost(broker.virtualHost) + factory.setUsername(broker.username) + factory.setPassword(broker.password) + // Recover the connection + topology automatically after a broker restart. + factory.setAutomaticRecoveryEnabled(true) + // Local SSL-context construction: calling RabbitMQUtils.createSSLContext would + // initialize that object, which hard-requires the global rabbitmq_connector.* props. + if (broker.useSsl) factory.useSslProtocol(createSslContext()) + logger.info(s"Open Corridor: connecting to bank ${broker.bankId} broker ${broker.host}:${broker.port}${broker.virtualHost}") + factory.newConnection(s"obp-open-corridor-${broker.bankId}") + } + }) + } + + /** + * Publish one Interface C message to the bank's vhost and await the Bank Node's + * reply envelope. `messageId` is the operation (`obp_credit_notification` / + * `obp_settlement_instruction` / ...); the body is the flat wire DTO. + * + * The returned envelope's `status.errorCode` is NOT interpreted here beyond + * parsing — callers must handle non-empty error codes explicitly (they must + * never be swallowed). + */ + def publishAndAwaitReply(bankId: String, messageId: String, wireBody: AnyRef): Future[Box[InBoundOpenCorridorReply]] = { + OpenCorridorBankBroker.findByBankId(bankId) match { + case Full(broker) => publishToBroker(broker, messageId, write(wireBody)) + case _ => Future.successful(Failure(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankId")) + } + } + + private def publishToBroker(broker: OpenCorridorBankBroker, messageId: String, bodyJson: String): Future[Box[InBoundOpenCorridorReply]] = { + val replyJsonFuture: Future[String] = + try { + val connection = connectionFor(broker) + val channel = connection.createChannel() + + val queueExists = try { + val tempChannel = connection.createChannel() + try { + tempChannel.queueDeclarePassive(RPC_QUEUE_NAME) + true + } finally { + if (tempChannel.isOpen) tempChannel.close() + } + } catch { + case _: java.io.IOException => false + } + if (!queueExists) { + channel.queueDeclare(RPC_QUEUE_NAME, true, false, false, rpcQueueArgs) + } + + val replyQueueName = channel.queueDeclare( + s"${REPLY_QUEUE_NAME_PREFIX}_${messageId.replace("obp_", "")}_${UUID.randomUUID.toString}", + false, true, true, replyQueueArgs + ).getQueue + + val correlationId = UUID.randomUUID().toString + val props = new BasicProperties.Builder() + .messageId(messageId) + .contentType("application/json") + .correlationId(correlationId) + .replyTo(replyQueueName) + .build() + + logger.info(s"Open Corridor publish: bank=${broker.bankId} messageId=$messageId correlationId=$correlationId replyTo=$replyQueueName") + logger.debug(s"Open Corridor publish body: $bodyJson") + channel.basicPublish("", RPC_QUEUE_NAME, props, bodyJson.getBytes("UTF-8")) + + val responseCallback = new ResponseCallback(correlationId, channel) + channel.basicConsume(replyQueueName, true, responseCallback, cancelCallback) + code.api.util.FutureUtil.futureWithTimeout(responseCallback.take())( + code.api.util.FutureUtil.EndpointTimeout(PUBLISH_RESPONSE_TIMEOUT_IN_MILLIS), + code.api.util.FutureUtil.EndpointContext(None), + global + ).andThen { + // On timeout the ResponseCallback never ran, so the per-publish channel is still open. + case scala.util.Failure(_) => + try { if (channel.isOpen) channel.close() } + catch { case e: Throwable => logger.debug(s"Open Corridor timeout channel cleanup failed: $e") } + } + } catch { + case e: Throwable => + logger.warn(s"Open Corridor publish failed: bank=${broker.bankId} messageId=$messageId", e) + Future.failed(e) + } + + replyJsonFuture.map { replyJson => + logger.debug(s"Open Corridor reply: bank=${broker.bankId} messageId=$messageId body=$replyJson") + net.liftweb.util.Helpers.tryo( + org.json4s.native.JsonMethods.parse(replyJson).extract[InBoundOpenCorridorReply] + ) match { + case Full(reply) => Full(reply) + case _ => Failure(s"$InvalidConnectorResponse Open Corridor reply did not parse as the inbound envelope. Body: $replyJson") + } + }.recover { + case e: Throwable => Failure(s"$OpenCorridorPublishFailed bank=${broker.bankId} messageId=$messageId Details: ${e.getMessage}") + } + } + + /** TLS 1.3 context from the instance keystore/truststore props. Local copy of + * `RabbitMQUtils.createSSLContext` — referencing that object would force its init, + * which hard-requires the global `rabbitmq_connector.*` props this publisher + * deliberately does not depend on. */ + private def createSslContext(): javax.net.ssl.SSLContext = { + import java.io.FileInputStream + import java.security.KeyStore + import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory} + + val keystorePath = APIUtil.getPropsValue("keystore.path").getOrElse("") + val keystorePassword = APIUtil.getPropsValue("keystore.password").getOrElse(APIUtil.initPasswd) + val truststorePath = APIUtil.getPropsValue("truststore.path").getOrElse("") + val truststorePassword = APIUtil.getPropsValue("truststore.password").getOrElse(APIUtil.initPasswd) + + val keyManagers = if (keystorePath.nonEmpty) { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) + val keystoreFile = new FileInputStream(keystorePath) + try keyStore.load(keystoreFile, keystorePassword.toCharArray) finally keystoreFile.close() + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + kmf.init(keyStore, keystorePassword.toCharArray) + kmf.getKeyManagers + } else null + + val trustManagers = if (truststorePath.nonEmpty) { + val trustStore = KeyStore.getInstance(KeyStore.getDefaultType) + val truststoreFile = new FileInputStream(truststorePath) + try trustStore.load(truststoreFile, truststorePassword.toCharArray) finally truststoreFile.close() + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + tmf.init(trustStore) + tmf.getTrustManagers + } else null + + val sslContext = SSLContext.getInstance("TLSv1.3") + sslContext.init(keyManagers, trustManagers, null) + sslContext + } + + /** Publish `obp_credit_notification` to the CREDITOR (beneficiary) bank's vhost. */ + def sendCreditNotification( + creditorBankId: String, + message: OutBoundOpenCorridorCreditNotification + ): Future[Box[InBoundOpenCorridorReply]] = + publishAndAwaitReply(creditorBankId, "obp_credit_notification", message) + + /** Publish `obp_settlement_instruction` to the DEBTOR bank's vhost. */ + def sendSettlementInstruction( + debtorBankId: String, + message: OutBoundOpenCorridorSettlementInstruction + ): Future[Box[InBoundOpenCorridorReply]] = + publishAndAwaitReply(debtorBankId, "obp_settlement_instruction", message) +} diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala index 68c567cd2e..99014e85be 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala @@ -7334,7 +7334,105 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { } // ---------- created on 2025-06-10T12:05:04Z -//---------------- dynamic end ---------------------please don't modify this line +//---------------- dynamic end ---------------------please don't modify this line + + // ─── Open Corridor Interface C messages (OBP-API → Bank Node) ────────────── + // + // These two MessageDocs LOCK the Open Corridor wire format. The consuming side + // is already implemented and transport-integration-tested in the OBP Bank Node + // (`OBP-Bank-Node/crates/obp-bank-node/src/interface_c/{types.rs,router.rs}`); + // any change here is a breaking change to that contract. + // + // Unlike the dynamic docs above, these messages are published server-initiated + // to the BANK's own RabbitMQ vhost (queue `obp_rpc_queue`), with the operation + // in the AMQP `messageId` property and the body serialized flat (lower_snake_case, + // no outboundAdapterCallContext wrapper). The reply arrives on `replyTo` + // correlated by `correlationId`, as the OBP inbound envelope with + // `status.errorCode == ""` meaning success. + + messageDocs += openCorridorCreditNotificationDoc + def openCorridorCreditNotificationDoc = MessageDoc( + process = "obp_credit_notification", + messageFormat = messageFormat, + description = "Open Corridor: notify the creditor (beneficiary) bank's Bank Node to credit its customer. " + + "Carries the commit–reveal evidence triplet (promise_commitment, promise_salt, promise_preimage) " + + "relayed verbatim from the originating Bank Node's promise report-back, so the beneficiary can verify " + + "SHA-256(salt ‖ preimage) against the on-chain commitment. Published to the creditor bank's vhost.", + outboundTopic = None, + inboundTopic = None, + exampleOutboundMessage = ( + OutBoundOpenCorridorCreditNotification( + transaction_request_id = "tr-abc-123", + value = OpenCorridorMoneyValue(currency = "KES", amount = "1500.00"), + description = Some("Invoice 4471"), + originator = Some(OpenCorridorOriginator(name = "Acme Coffee Ltd", address = Some("Nairobi"))), + netting_snapshot_id = Some("snap-1"), + promise_id = Some("63eacfe3dbc133f922d461bd3e6488ce21d55f03c5131cd79c965fe2e7491642"), + promise_blockchain = Some("cardano"), + promise_commitment = Some("9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1"), + promise_salt = Some("5f4dcc3b5aa765d61d8327deb882cf99"), + promise_preimage = Some("""{"tx_request_id":"tr-abc-123","instruction":"..."}""") + ) + ), + exampleInboundMessage = ( + InBoundOpenCorridorReply( + inboundAdapterCallContext = OpenCorridorInboundCallContext(correlationId = "1flssoftxq0cr1nssr68u0mioj"), + status = OpenCorridorReplyStatus(errorCode = "", backendMessages = Nil), + data = Extraction.decompose( + InBoundOpenCorridorCreditNotificationData( + transaction_request_id = "tr-abc-123", + verified = true, + cbs_reference = Some("CBS-1") + ) + )(DefaultFormats) + ) + ), + adapterImplementation = Some(AdapterImplementation("Open Corridor", 1)) + ) + + messageDocs += openCorridorSettlementInstructionDoc + def openCorridorSettlementInstructionDoc = MessageDoc( + process = "obp_settlement_instruction", + messageFormat = messageFormat, + description = "Open Corridor: instruct the debtor bank's Bank Node to settle the net amount on its " + + "settlement rail (e.g. cardano-ada). `amount` is in major units. `idempotency_key` is required — " + + "the Bank Node keeps a durable settlement record per key and never pays twice; re-publishing the same " + + "instruction returns the recorded state (redelivery doubles as SUBMITTED → FINAL status polling). " + + "Published to the debtor bank's vhost.", + outboundTopic = None, + inboundTopic = None, + exampleOutboundMessage = ( + OutBoundOpenCorridorSettlementInstruction( + snapshot_id = Some("snap-1"), + settlement_id = "settle-1", + settlement_system = "cardano-ada", + currency = "KES", + amount = "1500.00", + creditor_bank_id = "gh.29.uk", + creditor_address = "addr_test1vqn7sn79x6k9a2353l458mk2gccmwqk7nza93zydpuvl7lquy6jcl", + idempotency_key = "settle-1" + ) + ), + exampleInboundMessage = ( + InBoundOpenCorridorReply( + inboundAdapterCallContext = OpenCorridorInboundCallContext(correlationId = "1flssoftxq0cr1nssr68u0mioj"), + status = OpenCorridorReplyStatus(errorCode = "", backendMessages = Nil), + data = Extraction.decompose( + InBoundOpenCorridorSettlementData( + settlement_id = "settle-1", + status = "SUBMITTED", + tx_id = Some("787e857c1d49735603d283965b010c0c721aa4cdea627ec1ce8be266a5112845"), + blockchain = Some("cardano"), + asset = Some("ADA"), + asset_amount = Some("10.000000"), + depth = Some(0L), + finality_depth = Some(15L) + ) + )(DefaultFormats) + ) + ), + adapterImplementation = Some(AdapterImplementation("Open Corridor", 1)) + ) private val availableOperation = DynamicEntityOperation.values.map(it => s""""$it"""").mkString("[", ", ", "]") diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index 7c33375387..43512ad87d 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -39,6 +39,9 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with transactionRequests.map{ tr => for { transactionRequest <- tr.toTransactionRequest + // Open Corridor TRs are held at PENDING by design (promises awaiting the settle-pair + // netting step) — the external bulk-status feed must never flip them to COMPLETED. + if !transactionRequest.`type`.startsWith("OPEN_CORRIDOR") if (statuses.exists(i => i.transactionRequestId -> i.bulkTransactionsStatus == transactionRequest.id -> List("APVD"))) } yield { tr.updateStatus(TransactionRequestStatus.COMPLETED.toString) 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 c4d0f72b34..2589ac1e37 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 @@ -7,8 +7,8 @@ import code.api.util.http4s.Http4sStandardHeaders import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidJsonFormat, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, InvalidTransactionRequestId, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -1760,11 +1760,11 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { fromMap.get("account_id") shouldBe Some(JString(accountId)) case _ => fail("from should be an object") } - // 3.00 is far below the default 1000 EUR challenge threshold and the test props set - // no transaction_request_status_scheduler_delay, so the TR auto-completes today. - // NOTE: when the netting hold-at-PENDING change lands (OPEN_CORRIDOR_SIMPLE_NETTING.md §5), - // this assertion must flip to PENDING with empty transaction_ids. - map.get("status") shouldBe Some(JString("COMPLETED")) + // Hold-at-PENDING (OPEN_CORRIDOR_SIMPLE_NETTING.md §5): the promise never posts a + // Transaction at create time — it accumulates for bilateral netting and the + // settle-pair step posts the net later. + map.get("status") shouldBe Some(JString("PENDING")) + map.get("transaction_ids") shouldBe Some(JArray(Nil)) map.get("originator") match { case Some(JObject(origFields)) => val origMap = toFieldMap(origFields) @@ -1785,6 +1785,199 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── OPEN_CORRIDOR promise report-back (salt relay intake) ──────────────── + + private def promiseEvidencePath(bankId: String, accountId: String, transactionRequestId: String): String = + s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/transaction-requests/$transactionRequestId/open-corridor/promise" + + private def promiseEvidenceBody( + txHash: String = "63eacfe3dbc133f922d461bd3e6488ce21d55f03c5131cd79c965fe2e7491642", + commitment: String = "9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1" + ): String = + s"""{ + | "tx_hash": "$txHash", + | "blockchain": "cardano", + | "commitment": "$commitment", + | "salt": "5f4dcc3b5aa765d61d8327deb882cf99", + | "preimage": "{\\"tx_request_id\\":\\"tr-abc-123\\"}" + |}""".stripMargin + + /** Create an OPEN_CORRIDOR_PROMISE TR via the v7 endpoint; asserts hold-at-PENDING + * and returns the new TRANSACTION_REQUEST_ID. */ + private def createPendingPromise(): String = { + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + openCorridorPromisePath(testBankId1.value, testAccountId0.value), + openCorridorPromiseBody(acctCurrency), headers) + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("status") shouldBe Some(JString("PENDING")) + map.get("id") match { + case Some(JString(id)) if id.nonEmpty => id + case _ => fail("id should be a non-empty string") + } + case _ => fail("Expected JSON object") + } + } + + private def messageOf(json: JValue): String = json match { + case JObject(fields) => toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + + feature("Http4s700 attachOpenCorridorPromise (promise report-back) endpoint") { + + scenario("Reject unauthenticated POST", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, "some-tr-id"), + promiseEvidenceBody()) + statusCode shouldBe 401 + } + + scenario("Return 403 when authenticated without CanAttachOpenCorridorPromise", Http4s700RoutesTag) { + val headers = Map("DirectLogin" -> s"token=${token2.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, "some-tr-id"), + promiseEvidenceBody(), headers) + statusCode shouldBe 403 + messageOf(json) should include(UserHasMissingRoles) + messageOf(json) should include("CanAttachOpenCorridorPromise") + } + + scenario("Attach evidence: 201, idempotent re-post, conflict refused", Http4s700RoutesTag) { + Given("A PENDING OPEN_CORRIDOR_PROMISE and the role granted") + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + val transactionRequestId = createPendingPromise() + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("Evidence is attached the first time") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, transactionRequestId), + promiseEvidenceBody(), headers) + + Then("201 with the stored evidence and audit fields") + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("transaction_request_id") shouldBe Some(JString(transactionRequestId)) + map.get("transaction_request_status") shouldBe Some(JString("PENDING")) + map.get("tx_hash") shouldBe Some(JString("63eacfe3dbc133f922d461bd3e6488ce21d55f03c5131cd79c965fe2e7491642")) + map.get("blockchain") shouldBe Some(JString("cardano")) + map.get("commitment") shouldBe Some(JString("9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1")) + map.get("salt") shouldBe Some(JString("5f4dcc3b5aa765d61d8327deb882cf99")) + map.get("preimage") shouldBe Some(JString("""{"tx_request_id":"tr-abc-123"}""")) + map.get("reported_by_user_id") shouldBe Some(JString(resourceUser1.userId)) + map.get("reported_at") match { + case Some(JString(reportedAt)) => reportedAt should not be empty + case _ => fail("reported_at should be a non-empty string") + } + case _ => fail("Expected JSON object") + } + + When("The identical evidence is re-posted (Bank Node outbox redelivery)") + val (retryCode, retryJson, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, transactionRequestId), + promiseEvidenceBody(), headers) + + Then("201 with the stored record — idempotent") + retryCode shouldBe 201 + retryJson match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("commitment") shouldBe Some(JString("9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1")) + map.get("reported_by_user_id") shouldBe Some(JString(resourceUser1.userId)) + case _ => fail("Expected JSON object") + } + + When("Different evidence is posted for the same Transaction Request") + val (conflictCode, conflictJson, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, transactionRequestId), + promiseEvidenceBody(commitment = "0000000000000000000000000000000000000000000000000000000000000000"), headers) + + Then("400 — evidence is append-once") + conflictCode shouldBe 400 + messageOf(conflictJson) should include(OpenCorridorPromiseEvidenceConflict) + } + + scenario("Return 400 InvalidJsonValue when tx_hash is empty", Http4s700RoutesTag) { + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + val transactionRequestId = createPendingPromise() + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, transactionRequestId), + promiseEvidenceBody(txHash = ""), headers) + statusCode shouldBe 400 + messageOf(json) should include(InvalidJsonValue) + } + + scenario("Return 400 InvalidTransactionRequestId for an unknown Transaction Request", Http4s700RoutesTag) { + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, "no-such-transaction-request-id"), + promiseEvidenceBody(), headers) + statusCode shouldBe 400 + messageOf(json) should include(InvalidTransactionRequestId) + } + + scenario("Return 400 when the Transaction Request is not OPEN_CORRIDOR_PROMISE", Http4s700RoutesTag) { + Given("A PENDING Transaction Request of type SIMPLE created via the provider") + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + val fromAccount = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1).openOrThrowException("test from account") + val toAccount = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId2, testAccountId1, None) + .map(_._1).openOrThrowException("test to account") + val simpleTr = code.transactionrequests.TransactionRequests.transactionRequestProvider.vend + .createTransactionRequestImpl210( + com.openbankproject.commons.model.TransactionRequestId(APIUtil.generateUUID()), + com.openbankproject.commons.model.TransactionRequestType("SIMPLE"), + fromAccount, + toAccount, + com.openbankproject.commons.model.TransactionRequestCommonBodyJSONCommons( + com.openbankproject.commons.model.AmountOfMoneyJsonV121(fromAccount.currency, "3.00"), "wrong type"), + "{}", + "PENDING", + com.openbankproject.commons.model.TransactionRequestCharge( + "Total charges for completed transaction", + com.openbankproject.commons.model.AmountOfMoney(fromAccount.currency, "0.00")), + "SHARED", + None, None, None, None, None + ).openOrThrowException("SIMPLE TR should be created") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, simpleTr.id.value), + promiseEvidenceBody(), headers) + statusCode shouldBe 400 + messageOf(json) should include(OpenCorridorPromiseTypeMismatch) + } + + scenario("Return 400 when the promise is no longer PENDING", Http4s700RoutesTag) { + Given("A promise flipped to COMPLETED via the provider (as the settle step will do)") + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + val transactionRequestId = createPendingPromise() + code.transactionrequests.TransactionRequests.transactionRequestProvider.vend + .saveTransactionRequestStatusImpl( + com.openbankproject.commons.model.TransactionRequestId(transactionRequestId), "COMPLETED") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, transactionRequestId), + promiseEvidenceBody(), headers) + statusCode shouldBe 400 + messageOf(json) should include(OpenCorridorPromiseNotPending) + } + } + // ─── BULK transaction request ───────────────────────────────────────────── /** Fresh batch reference for each test scenario to avoid idempotency collisions. */ diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala b/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala new file mode 100644 index 0000000000..673a43fb49 --- /dev/null +++ b/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala @@ -0,0 +1,130 @@ +package com.openbankproject.commons.dto + +import org.json4s.JValue + +/** + * Open Corridor — Interface C wire DTOs (OBP-API → Bank Node over RabbitMQ). + * + * LOCKED WIRE CONTRACT. The consuming side is already implemented and + * transport-integration-tested in the OBP Bank Node + * (`OBP-Bank-Node/crates/obp-bank-node/src/interface_c/types.rs` + `router.rs`); + * these classes must serialize to exactly that shape. + * + * Unlike the stock connector DTOs, these bodies carry NO + * `outboundAdapterCallContext` wrapper and the field names ARE the wire format + * (lower_snake_case): OBP-API publishes one AMQP message per operation to the + * bank's vhost queue `obp_rpc_queue` with the AMQP properties carrying the + * routing metadata (`messageId` = operation, `correlationId`, `replyTo`), and + * the Bank Node parses the body directly into its own snake_case types. + * + * The reply travels as the OBP inbound envelope (`InBoundOpenCorridorReply`): + * camelCase envelope keys, `status.errorCode == ""` meaning success, and a + * message-specific snake_case `data` object. + */ + +case class OpenCorridorMoneyValue( + currency: String, + amount: String +) + +case class OpenCorridorOriginator( + name: String, + address: Option[String] +) + +/** + * `obp_credit_notification` — published to the CREDITOR (beneficiary) bank's + * vhost: "credit your customer". The three `promise_*` evidence fields are the + * point of the salt relay: they carry the commit–reveal data (commitment, salt, + * preimage) so the beneficiary bank can open the originating bank's on-chain + * commitment without its cooperation. They are opaque to OBP-API — relayed + * verbatim from what the originating Bank Node reported back (§5.1 report-back + * endpoint), never parsed. + */ +case class OutBoundOpenCorridorCreditNotification( + transaction_request_id: String, + value: OpenCorridorMoneyValue, + description: Option[String], + originator: Option[OpenCorridorOriginator], + netting_snapshot_id: Option[String], + promise_id: Option[String], + promise_blockchain: Option[String], + promise_commitment: Option[String], + promise_salt: Option[String], + promise_preimage: Option[String] +) + +/** + * Reply `data` for `obp_credit_notification`: the bank verified the evidence + * (recomputed SHA-256(salt ‖ preimage) against the commitment) and delivered + * the credit to its CBS. + */ +case class InBoundOpenCorridorCreditNotificationData( + transaction_request_id: String, + verified: Boolean, + cbs_reference: Option[String] +) + +/** + * `obp_settlement_instruction` — published to the DEBTOR bank's vhost: "settle + * the net now on your rail". `amount` is in MAJOR units (the Bank Node parses + * to minor units itself, assuming 2 decimals — documented limitation for + * non-2-exponent currencies). `idempotency_key` is REQUIRED by the consumer: + * it keeps a durable settlement record per key and will never pay twice for + * the same key; re-publishing the same instruction returns the recorded state + * (redelivery doubles as status polling for the SUBMITTED → FINAL transition). + */ +case class OutBoundOpenCorridorSettlementInstruction( + snapshot_id: Option[String], + settlement_id: String, + settlement_system: String, + currency: String, + amount: String, + creditor_bank_id: String, + creditor_address: String, + idempotency_key: String +) + +/** + * Reply `data` for `obp_settlement_instruction`. `status` is one of: + * - SETTLING — an attempt is in flight (or crashed mid-flight; the node will + * not auto-retry an ambiguous attempt) + * - SUBMITTED — broadcast to the chain, NOT yet final + * - FINAL — confirmed at >= finality_depth; treat as settled only now + */ +case class InBoundOpenCorridorSettlementData( + settlement_id: String, + status: String, + tx_id: Option[String], + blockchain: Option[String], + asset: Option[String], + asset_amount: Option[String], + depth: Option[Long], + finality_depth: Option[Long] +) + +/** Envelope keys are camelCase on the wire — matches the Bank Node's `ReplyEnvelope`. */ +case class OpenCorridorInboundCallContext( + correlationId: String +) + +/** `errorCode == ""` means success. Non-empty codes are the Bank Node's + * `OBP-BANK-NODE-*` failures (COMMITMENT-MISMATCH, CBS-DELIVERY-FAILED, + * SETTLEMENT-FAILED, SETTLEMENT-NOT-CONFIGURED, BAD-MESSAGE, NOT-IMPLEMENTED) + * and must never be swallowed by the caller. */ +case class OpenCorridorReplyStatus( + errorCode: String, + backendMessages: List[String] +) + +/** + * The reply envelope the Bank Node sends on `replyTo`, matched by + * `correlationId`. `data` stays a raw JValue here because its shape depends on + * the messageId; parse it into `InBoundOpenCorridorCreditNotificationData` / + * `InBoundOpenCorridorSettlementData` at the call site. + */ +case class InBoundOpenCorridorReply( + inboundAdapterCallContext: OpenCorridorInboundCallContext, + status: OpenCorridorReplyStatus, + data: JValue +) From ecfe6b7a88a2a50eb0bf11561a6f17ee4bc26e0d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 28 Jul 2026 09:29:22 +0200 Subject: [PATCH 3/7] mTLS tweaks --- docs/MTLS.md | 2 +- docs/MTLS_TOPOLOGIES.md | 8 ++++ .../resources/props/sample.props.template | 2 + .../scala/bootstrap/http4s/Http4sMtls.scala | 46 +++++++++++++------ .../scala/bootstrap/http4s/Http4sServer.scala | 5 ++ .../main/scala/code/api/util/ApiSession.scala | 4 +- .../main/scala/code/api/util/PeerTrust.scala | 23 +++++++++- .../code/api/util/http4s/Http4sSupport.scala | 6 ++- .../bootstrap/http4s/Http4sMtlsTest.scala | 18 ++++++-- .../bootstrap/http4s/NginxForwarderTest.scala | 45 ++++++++++++------ .../scala/code/api/util/PeerTrustTest.scala | 24 ++++++++++ 11 files changed, 146 insertions(+), 37 deletions(-) diff --git a/docs/MTLS.md b/docs/MTLS.md index 343a90444e..d366dea6df 100644 --- a/docs/MTLS.md +++ b/docs/MTLS.md @@ -347,7 +347,7 @@ Consumer — presenting a different client certificate is rejected. | `mtls.client_auth` | `need` | `need` rejects certless handshakes; `want` makes the client certificate optional. | | `mtls.trusted_proxy.N.issuer` | — | Issuer DN of a peer allowed to forward someone else's certificate. Indexed from 1; scanning stops at the first missing index. Empty (the default) means OBP is the edge. | | `mtls.trusted_proxy.N.subject` | any | Subject DN of that peer. `*` or unset accepts any subject the issuer signed — free proxy rotation, but only as tight as that CA. | -| `mtls.trust_forwarded_header_without_tls` | `true` | Whether a `PSD2-CERT` header is trusted when the sender presented no client certificate. `true` is the pre-existing behaviour of a plain proxy hop; set it to `false` once the proxy authenticates itself. | +| `mtls.trust_forwarded_header_without_tls` | `true` | Whether a `PSD2-CERT` header is trusted when the sender presented no client certificate. `true` is the pre-existing behaviour of a plain proxy hop; set it to `false` once the proxy authenticates itself. **Ignored (treated as `false`) when `mtls.enabled=true` and no trusted proxies are configured** — OBP is then the TLS edge, so a header from a certless peer (possible under `client_auth=want`) can only be a spoofing attempt and is stripped. | DNs are compared in canonical form, so case and spacing do not matter — but **RDN order does**. Print the exact values to paste with diff --git a/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md index 528fb9686e..30b0c6d094 100644 --- a/docs/MTLS_TOPOLOGIES.md +++ b/docs/MTLS_TOPOLOGIES.md @@ -208,6 +208,14 @@ setting. Mitigate it by logging a warning at boot whenever it resolves to `true` noisy rather than silent, and remove the default entirely once §11.5 has rolled through every environment. +One deployment shape is carved out of the default (added 2026-07-28): when `mtls.enabled=true` +and the forwarder allowlist is empty, OBP is provably the edge — every legitimate caller identity +arrives in the handshake — so the prop is ignored and the header stripped. Honouring the +permissive default there would have re-opened a hole the pre-generalisation middleware had +closed: under `mtls.client_auth=want` a certless peer could spoof `PSD2-CERT`, where +`injectClientCertificate` used to strip it unconditionally. `PeerTrust.effectiveTrustWithoutTls` +implements the carve-out; the prop keeps its meaning for the plain-hop and behind-proxy shapes. + ### 5.5 Observability Record, per request, which branch of §3 resolved (direct caller vs forwarded) and the peer subject, diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 7793bdc2ee..fce30960d1 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -177,6 +177,8 @@ jwt.use.ssl=false # mtls.trusted_proxy.1.subject=CN=nginx-prod-1,O=Your Org,C=DE ## Whether a forwarded PSD2-CERT is trusted when the sender presented no client certificate. ## True is the long-standing behaviour of a plain proxy hop; set false once the proxy uses mTLS. +## Ignored (treated as false) when mtls.enabled=true and no trusted proxies are configured: +## OBP is then the TLS edge, so a header from a certless peer can only be a spoofing attempt. # mtls.trust_forwarded_header_without_tls=true # mtls.enabled=true # mtls.keystore.path=obp-api/src/test/resources/cert/server.jks diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala index 5cb380dac8..d06efb2546 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala @@ -63,17 +63,29 @@ object Http4sMtls extends MdcLoggable { private val DevStorePassword = "123456" /** - * SHA-256 of the checked-in dev stores, so they can be recognised wherever they are copied to. + * SHA-256 of EVERY store checked into `obp-api/src/test/resources/cert`, keyed by filename, so + * they can be recognised wherever they are copied to. * - * These are public: the private key is in the repository and the password is in this file. A - * production server using either would accept forged client certificates and present a - * certificate anyone can impersonate, so [[assertNotADevStoreInProduction]] refuses to boot. + * All of them are public: the private keys are in the repository and the password is in the + * source. A production server using any of them would accept forged client certificates and + * present a certificate anyone can impersonate, so [[assertNotADevStoreInProduction]] refuses to + * boot. The list must cover both the legacy JKS pair (the prop defaults) and the role-named + * PKCS12 set that generate_dev_certs.sh writes — docs/MTLS.md steers readers to the latter, so + * guarding only the pair the props default to would leave the recommended set unguarded. * - * Http4sMtlsTest asserts these digests still match the files, so regenerating the dev pair fails - * the build here rather than silently disarming the check. + * Http4sMtlsTest asserts these digests still match the files, so regenerating any dev store + * fails the build here rather than silently disarming the check. */ - private[http4s] val DevKeystoreSha256 = "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb" - private[http4s] val DevTruststoreSha256 = "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0" + private[http4s] val DevStoreDigests: Map[String, String] = Map( + "server.jks" -> "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb", + "server.trust.jks" -> "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0", + "localhost_san_dns_ip.pfx" -> "e78c09858fe0659bc67d14570e67e57d750280d10db43980992e54ba60a7427d", + "obp-server.p12" -> "e56ec88d23fb1691d494090f6abec98332b3410e75251814f70ac0ed981bf918", + "dev-truststore.p12" -> "9c606b3012ffc01e85d26c196b80d1ad5f731fa6a7b5c6ed94736d79d800e3ec", + "tpp-client.p12" -> "717bdb83aa3d85f6245cf99762a080cfdf45d9e87de699d7339e600a90754e4a", + "proxy-client.p12" -> "e7f1e212a25642daaabd587184836afcf05506cdf19eb5ad570bdbe3352d82a8", + "expired-tpp.p12" -> "42c9564f96b21354f3b0f9a91925c2e81a22bad6850070d1618966a2cafe0e09" + ) private[http4s] def sha256Of(file: File): String = { val digest = java.security.MessageDigest.getInstance("SHA-256") @@ -101,9 +113,9 @@ object Http4sMtls extends MdcLoggable { private[http4s] def assertNotADevStoreInProduction(propName: String, file: File): Unit = if (Props.mode == Props.RunModes.Production) { val digest = sha256Of(file) - if (digest == DevKeystoreSha256 || digest == DevTruststoreSha256) { + DevStoreDigests.find(_._2 == digest).foreach { case (knownAs, _) => throw new RuntimeException( - s"'$propName' points at ${file.getAbsolutePath}, which is one of the development stores " + + s"'$propName' points at ${file.getAbsolutePath}, which is the development store '$knownAs' " + "checked into the OBP-API repository. Its private key is public and its password is " + "'123456', so it cannot be used with run.mode=production. Supply your own certificates.") } @@ -118,7 +130,8 @@ object Http4sMtls extends MdcLoggable { // Returns the absolute store path plus its password, defaulting both to the dev pair. def store(pathProp: String, devPath: String, passwordProp: String): (String, String) = { - val path = prop(pathProp).getOrElse { + val configuredPath = prop(pathProp) + val path = configuredPath.getOrElse { logger.warn(s"'$pathProp' is not set — falling back to the checked-in dev store '$devPath'. " + s"Set '$pathProp' (or ${envVarOf(pathProp)}) to use your own certificates.") devPath @@ -129,9 +142,16 @@ object Http4sMtls extends MdcLoggable { s"(resolved to ${file.getAbsolutePath} from working directory ${new File(".").getAbsolutePath}). " + s"Launch from the repo root or set '$pathProp' to an absolute path.") assertNotADevStoreInProduction(pathProp, file) + // The password only follows the dev default when the store itself does. An operator-supplied + // store with a missing password prop must fail here, by name — falling back to '123456' + // would surface later as an opaque keystore integrity error instead. val password = prop(passwordProp).getOrElse { - logger.warn(s"'$passwordProp' is not set — falling back to the checked-in dev store password.") - DevStorePassword + if (configuredPath.isEmpty) { + logger.warn(s"'$passwordProp' is not set — using the checked-in dev store password to match the dev store.") + DevStorePassword + } else throw new RuntimeException( + s"'$pathProp' is set but '$passwordProp' is not. Set '$passwordProp' (or ${envVarOf(passwordProp)}) " + + "to the password of that store.") } (file.getAbsolutePath, password) } diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index d12da825ac..a2e7dd53c6 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -22,6 +22,11 @@ object Http4sServer extends IOApp with MdcLoggable { val httpApp = Http4sApp.httpApp override def run(args: List[String]): IO[ExitCode] = { + // Force the peer-trust configuration at boot. It is a lazy val first needed when a request + // carries certificate material, so without this an unparseable mtls.trusted_proxy.N DN (logged + // at ERROR, proxy silently untrusted) would surface mid-traffic instead of in the boot log. + code.api.util.PeerTrust.config + val builder = EmberServerBuilder .default[IO] .withHost(Host.fromString(host).get) diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index aed7274b77..9259ad498b 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -62,8 +62,8 @@ case class CallContext( // Set when the request is authenticated via a consent. Persisted on metric rows for search/audit. consentReferenceId: Option[String] = None, // How the caller's certificate was established: "direct", "forwarded via ", - // or "none: ". See PeerTrust.Resolution — the audit trail for the trust - // decision that turned a TLS peer plus a header into an identity. + // or "none: ". See PeerTrust.Resolution — the trust decision that turned a + // TLS peer plus a header into an identity. Not yet persisted on metric rows. certificateTrust: Option[String] = None ) extends MdcLoggable { override def toString: String = SecureLogging.maskSensitive( diff --git a/obp-api/src/main/scala/code/api/util/PeerTrust.scala b/obp-api/src/main/scala/code/api/util/PeerTrust.scala index 5ac529d344..4e7904ef84 100644 --- a/obp-api/src/main/scala/code/api/util/PeerTrust.scala +++ b/obp-api/src/main/scala/code/api/util/PeerTrust.scala @@ -182,7 +182,9 @@ object PeerTrust extends MdcLoggable { } }.toList - val trustWithoutTls = APIUtil.getPropsAsBoolValue(TrustForwardedWithoutTlsProp, defaultValue = true) + val trustWithoutTlsProp = APIUtil.getPropsAsBoolValue(TrustForwardedWithoutTlsProp, defaultValue = true) + val mtlsEnabled = APIUtil.getPropsAsBoolValue("mtls.enabled", defaultValue = false) + val trustWithoutTls = effectiveTrustWithoutTls(trustWithoutTlsProp, proxies.isEmpty, mtlsEnabled) if (proxies.isEmpty) { logger.info("No mtls.trusted_proxy.N.issuer configured: OBP treats its TLS peer as the caller " + @@ -190,6 +192,11 @@ object PeerTrust extends MdcLoggable { } else { logger.info(s"Trusted forwarders: ${proxies.map(p => p.subject.getOrElse("") + " issued by " + p.issuer).mkString("; ")}") } + if (trustWithoutTlsProp && !trustWithoutTls) { + logger.info(s"$TrustForwardedWithoutTlsProp=true is ignored: mtls.enabled=true with no trusted " + + "proxies makes OBP the TLS edge, so a PSD2-CERT header from a peer that presented no client " + + "certificate can only be a spoofing attempt and is stripped.") + } if (trustWithoutTls) { // Deliberately noisy: the default is the permissive setting, chosen so that existing // deployments upgrade unchanged. It should not be able to sit there unnoticed. @@ -199,4 +206,18 @@ object PeerTrust extends MdcLoggable { } TrustConfig(proxies, trustWithoutTls) } + + /** + * Whether a `PSD2-CERT` header on a request with no TLS client certificate may name the caller. + * + * The prop exists for the plain-proxy-hop deployment, where OBP terminates no TLS and the header + * is all there is. But when OBP terminates mTLS itself AND no trusted forwarder is configured, + * OBP is provably the edge: every legitimate caller identity arrives in the handshake, so a + * header on a certless request (possible under `mtls.client_auth=want`) can only be a spoofing + * attempt. The pre-generalisation middleware (`Http4sMtls.injectClientCertificate`) always + * stripped it in that deployment; honouring the prop's permissive default there would silently + * re-open the hole the old code closed, so the prop is ignored for that one shape. + */ + private[util] def effectiveTrustWithoutTls(propValue: Boolean, noProxiesConfigured: Boolean, mtlsEnabled: Boolean): Boolean = + propValue && !(mtlsEnabled && noProxiesConfigured) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index ef426f62cf..d8bce10c44 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -71,8 +71,10 @@ object Http4sRequestAttributes { * Vault key for how the caller's certificate was established — "direct", "forwarded via ", * or "none: " (see PeerTrust.Resolution.describe). * - * Carried onto the CallContext so it reaches the metric row. Without it, "why is this TPP suddenly - * anonymous" is answerable only by guessing at the deployment's trust configuration. + * Carried onto the CallContext (CallContext.certificateTrust) so metrics and audit CAN record it. + * NOTE: nothing persists it to metric rows yet — until that lands, the per-request trail is the + * CallerCertificate debug log. Without at least that, "why is this TPP suddenly anonymous" is + * answerable only by guessing at the deployment's trust configuration. */ val callerCertificateTrustKey: Key[String] = Key.newKey[IO, String].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) diff --git a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala index ae4da7bb9b..28c1b02a8b 100644 --- a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala +++ b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala @@ -47,12 +47,24 @@ class Http4sMtlsTest extends FlatSpec with Matchers { // CallerCertificateTest, where they are now one row of a table rather than the whole behaviour. // The dev-store guard recognises these files by digest wherever they have been copied to, so the - // constants must track the files. Regenerating the dev pair without updating them would leave a + // constants must track the files. Regenerating any dev store without updating them would leave a // check that silently matches nothing — this test is what turns that into a build failure. "the dev store digests" should "still match the checked-in files" in { val certDir = new java.io.File(getClass.getResource(ServerJksResource).toURI).getParentFile - Http4sMtls.sha256Of(new java.io.File(certDir, "server.jks")) shouldEqual Http4sMtls.DevKeystoreSha256 - Http4sMtls.sha256Of(new java.io.File(certDir, "server.trust.jks")) shouldEqual Http4sMtls.DevTruststoreSha256 + Http4sMtls.DevStoreDigests.foreach { case (name, digest) => + withClue(s"$name (regenerated without updating Http4sMtls.DevStoreDigests?): ") { + Http4sMtls.sha256Of(new java.io.File(certDir, name)) shouldEqual digest + } + } + } + + it should "cover every store checked into the cert directory" in { + // A store the map does not know is a store the production guard will not refuse to boot on. + // .crt/.key PEM files are not loadable as keystores, so only store extensions matter here. + val certDir = new java.io.File(getClass.getResource(ServerJksResource).toURI).getParentFile + val storeFiles = certDir.listFiles().map(_.getName) + .filter(n => n.endsWith(".jks") || n.endsWith(".p12") || n.endsWith(".pfx")) + storeFiles.toSet shouldEqual Http4sMtls.DevStoreDigests.keySet } // Outside Production the guard must not fire: development is the whole point of the dev pair. diff --git a/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala b/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala index 8be4b62775..8e053aedf7 100644 --- a/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala +++ b/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala @@ -42,8 +42,13 @@ import scala.util.Try class NginxForwarderTest extends FlatSpec with Matchers with BeforeAndAfterAll { private val NginxImage = "nginx:1.27-alpine" - private val NginxContainer = "obp-nginx-forwarder-test" - private val NginxPort = 18453 + // An OS-assigned free port rather than a fixed one, so two runs on one host (e.g. parallel CI + // shards) cannot collide; the container name carries it for the same reason. + private val NginxPort = { + val socket = new java.net.ServerSocket(0) + try socket.getLocalPort finally socket.close() + } + private val NginxContainer = s"obp-nginx-forwarder-test-$NginxPort" private val Password = "123456" private val certDir = new File(getClass.getResource("/cert/dev-ca.crt").toURI).getParentFile @@ -82,6 +87,10 @@ class NginxForwarderTest extends FlatSpec with Matchers with BeforeAndAfterAll { private var shutdown: IO[Unit] = IO.unit private var emberPort: Int = 0 + // True only once nginx is answering through the proxy. Environments where Docker exists but the + // container cannot reach the host's loopback (--network host is a no-op on Docker Desktop for + // Mac/Windows) skip the suite rather than fail it; the reason is printed by startNginx. + private var nginxUp: Boolean = false override def beforeAll(): Unit = if (dockerAvailable) { // Upstream: OBP's server identity, requiring nginx to present a client cert it trusts (the CA). @@ -134,20 +143,26 @@ class NginxForwarderTest extends FlatSpec with Matchers with BeforeAndAfterAll { try out.write(conf.getBytes("UTF-8")) finally out.close() Seq("docker", "rm", "-f", NginxContainer).run(ProcessLogger(_ => ())).exitValue() - val run = Seq("docker", "run", "--rm", "-d", "--name", NginxContainer, "--network", "host", + val run = Try(Seq("docker", "run", "--rm", "-d", "--name", NginxContainer, "--network", "host", "-v", s"${confFile.getAbsolutePath}:/etc/nginx/nginx.conf:ro", "-v", s"${certDir.getAbsolutePath}:/certs:ro", - NginxImage).!!.trim - withClue(s"docker run did not return a container id (got '$run')") { run.length should be > 10 } + NginxImage).!!.trim) + if (run.isFailure || run.get.length <= 10) { + println(s"NginxForwarderTest: docker run failed (${run.fold(_.getMessage, identity)}) — suite will be skipped") + return + } - // Wait for nginx to accept TLS on its port. + // Wait for nginx to accept TLS on its port. Not coming up is a skip, not a failure: with + // --network host unsupported (Docker Desktop) nginx can never reach the Ember upstream on the + // host loopback, and that environment limitation should not read as a middleware regression. val deadline = System.currentTimeMillis() + 20000 - var up = false - while (!up && System.currentTimeMillis() < deadline) { - up = Try(get("/trusted", withClientCert = true)).isSuccess - if (!up) Thread.sleep(500) + while (!nginxUp && System.currentTimeMillis() < deadline) { + nginxUp = Try(get("/trusted", withClientCert = true)).isSuccess + if (!nginxUp) Thread.sleep(500) } - withClue(s"nginx did not come up on $NginxPort within 20s") { up shouldBe true } + if (!nginxUp) + println(s"NginxForwarderTest: nginx did not answer on $NginxPort within 20s " + + "(no host networking for containers on this Docker?) — suite will be skipped") } private def clientContext(withClientCert: Boolean): SSLContext = { @@ -178,7 +193,7 @@ class NginxForwarderTest extends FlatSpec with Matchers with BeforeAndAfterAll { // ---- the four risks, through real nginx -------------------------------------------------------- "a request forwarded by a trusted nginx" should "resolve the caller from the URL-encoded PSD2-CERT" in { - assume(dockerAvailable, "Docker not available — skipping the real-nginx harness") + assume(nginxUp, "nginx harness not running (no Docker, or no host networking) — skipping") val r = get("/trusted", withClientCert = true) // nginx sent $ssl_client_escaped_cert (URL-encoded); Psd2CertIngress decoded it to canonical PEM. r.body shouldEqual tppPem @@ -186,21 +201,21 @@ class NginxForwarderTest extends FlatSpec with Matchers with BeforeAndAfterAll { } "a client-supplied PSD2-CERT header" should "be overwritten by the verified certificate" in { - assume(dockerAvailable, "Docker not available") + assume(nginxUp, "nginx harness not running (no Docker, or no host networking) — skipping") val r = get("/trusted", withClientCert = true, spoof = Some("-----BEGIN CERTIFICATE-----SPOOF-----END CERTIFICATE-----")) r.body shouldEqual tppPem // the TPP's real cert, not the spoof r.body should not include "SPOOF" } "a peer outside the allowlist" should "be treated as the caller, its forwarded header discarded" in { - assume(dockerAvailable, "Docker not available") + assume(nginxUp, "nginx harness not running (no Docker, or no host networking) — skipping") val r = get("/untrusted", withClientCert = true) r.body shouldEqual proxyPem // nginx IS the caller now, not the forwarded TPP r.trust shouldEqual "direct" } "the missed-overwrite misconfiguration" should "let a spoofed header through — which is why overwrite matters" in { - assume(dockerAvailable, "Docker not available") + assume(nginxUp, "nginx harness not running (no Docker, or no host networking) — skipping") val r = get("/missed", withClientCert = true, spoof = Some("SPOOFED-CALLER-IDENTITY")) // nginx forwarded the CLIENT's header instead of the verified cert, and the app trusts nginx, // so the spoof reaches the caller identity. The harness exists to make this failure visible. diff --git a/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala b/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala index 5fa4681475..4e5b8ef347 100644 --- a/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala +++ b/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala @@ -117,4 +117,28 @@ class PeerTrustTest extends FlatSpec with Matchers { canonicalDn("nginx-prod-1") shouldBe None canonicalDn("") shouldBe None } + + // mtls.trust_forwarded_header_without_tls exists for the plain-proxy-hop deployment. When OBP + // terminates mTLS itself and no forwarder is configured it is provably the edge, and the prop's + // permissive default would re-open the spoofing hole the pre-generalisation middleware closed + // (a certless peer under client_auth=want sending its own PSD2-CERT). + "trust_forwarded_header_without_tls" should "be ignored when OBP terminates mTLS as the edge" in { + PeerTrust.effectiveTrustWithoutTls(propValue = true, noProxiesConfigured = true, mtlsEnabled = true) shouldBe false + } + + it should "be honoured on a plain proxy hop, where the header is all there is" in { + PeerTrust.effectiveTrustWithoutTls(propValue = true, noProxiesConfigured = true, mtlsEnabled = false) shouldBe true + } + + it should "be honoured when trusted proxies are configured, mTLS or not" in { + // With an allowlist the deployment is behind-proxy; certless hops may still be legitimate + // (e.g. one proxy already on mTLS, another not yet migrated). + PeerTrust.effectiveTrustWithoutTls(propValue = true, noProxiesConfigured = false, mtlsEnabled = true) shouldBe true + PeerTrust.effectiveTrustWithoutTls(propValue = true, noProxiesConfigured = false, mtlsEnabled = false) shouldBe true + } + + it should "never turn an explicit false back on" in { + PeerTrust.effectiveTrustWithoutTls(propValue = false, noProxiesConfigured = true, mtlsEnabled = true) shouldBe false + PeerTrust.effectiveTrustWithoutTls(propValue = false, noProxiesConfigured = false, mtlsEnabled = false) shouldBe false + } } From 565cc266b187f204277b94f41aa04dd1c7c84262 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 28 Jul 2026 11:50:11 +0200 Subject: [PATCH 4/7] Open Corridor work WIP --- OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md | 37 ++ .../main/scala/bootstrap/liftweb/Boot.scala | 8 +- .../main/scala/code/api/util/ApiRole.scala | 5 + .../scala/code/api/util/ErrorMessages.scala | 2 + .../scala/code/api/v7_0_0/Http4s700.scala | 81 ++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 37 +- .../bankconnectors/LocalMappedConnector.scala | 35 +- .../opencorridor/OpenCorridorBankBroker.scala | 10 +- .../opencorridor/OpenCorridorOutbox.scala | 80 +++++ .../OpenCorridorOutboxRelay.scala | 129 +++++++ .../opencorridor/OpenCorridorPublisher.scala | 8 +- .../opencorridor/OpenCorridorSettlement.scala | 292 ++++++++++++++++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 321 +++++++++++++++++- 13 files changed, 1009 insertions(+), 36 deletions(-) create mode 100644 obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala create mode 100644 obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala create mode 100644 obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala diff --git a/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md b/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md index b72cf2d967..7242e077bc 100644 --- a/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md +++ b/OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md @@ -312,6 +312,20 @@ TransactionRequest: ### 5.2 Server-initiated publish capability +> **BUILT (2026-07-28, branch `open-corridor-salt-relay`).** +> `OpenCorridorBankBroker` (Mapper table, unique per bank_id: host/port/vhost/ +> credentials/use_ssl + `settlement_address`, schemified in Boot) with v7 admin +> endpoints `PUT|GET|DELETE /banks/BANK_ID/open-corridor/broker` (system role +> `CanConfigureOpenCorridorBroker`; password write-only, never echoed). +> `OpenCorridorPublisher` does publish-and-await-reply to the bank's vhost +> (queue `obp_rpc_queue`, AMQP messageId/correlationId/replyTo, §4.2 envelope +> parse) with one cached auto-recovering connection per bank — deliberately +> self-contained from `RabbitMQUtils` (whose object init hard-requires the +> global `rabbitmq_connector.*` props describing the OBP adapter broker, not +> these per-bank brokers). Errors OBP-40054 (no broker registered) / +> OBP-40055 (publish failed). Open decision 5 (creditor address source) is +> RESOLVED: `settlement_address` lives on the broker registration row. + Add a publish-and-await-reply to the bank's vhost. **Reuse the existing RPC shape** (`RabbitMQUtils.sendRequestUndGetResponseFromRabbitMQ`, `:90`) — it already does publish-to-`obp_rpc_queue` + await-on-`replyTo` + correlate. The new @@ -332,6 +346,29 @@ parts: ### 5.3 The trigger: the admin settle-pair endpoint (revised 2026-07-18) +> **BUILT (2026-07-28, branch `open-corridor-salt-relay`).** +> `POST /obp/v7.0.0/open-corridor/settle` `{bank_id_a, bank_id_b, currency}`, +> system role `CanSettleOpenCorridor`, gated by `open_corridor_enabled` +> (OBP-40057). `OpenCorridorSettlement.settlePair`: row-locks each candidate +> promise (re-read under lock, so a concurrent double-trigger cannot settle the +> same promises twice; no-pending re-trigger is a no-op), nets both directions, +> mints + executes TR B between the pair's settlement accounts +> (`OBP-OUTGOING-SETTLEMENT-ACCOUNT` → `OBP-INCOMING-SETTLEMENT-ACCOUNT`), +> writes `settled_by_transaction_ids` AND `settled_by_transaction_request_id` +> (TR B's id — kept even at net zero) attributes on each covered promise, +> flips them COMPLETED, and enqueues the messages into the `OpenCorridorOutbox` +> Mapper table in the same request DB transaction. `OpenCorridorOutboxRelay` +> (Boot-started when `open_corridor_enabled`, `open_corridor.outbox_relay_interval` +> default 10s) publishes with exponential backoff and records each §4.2 reply: +> settlement rows stay PENDING through SUBMITTED/SETTLING (redelivery-as-polling, +> §4.4) until FINAL; refutable business errors (COMMITMENT-MISMATCH etc.) go +> STICKY for operator reconciliation, never swallowed. Fails fast pre-mutation +> when either bank lacks a broker registration or (net ≠ 0) the creditor lacks a +> `settlement_address` (OBP-40056). **Open decision 9 (net-equals-zero) is +> RESOLVED:** TR B is still minted (audit object) and completes with empty +> `transaction_ids`; promises discharge with `settled_by_transaction_request_id` +> only; no settlement instruction is sent; credit notifications still go out. + The trigger is the settle-pair endpoint from `OPEN_CORRIDOR_SIMPLE_NETTING.md` §6 — **not** a per-promise "settle now" action. With one pending promise it degenerates to exactly the old behaviour, so nothing is lost and real netting diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 594fd36d9e..ce8eac7691 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -135,7 +135,7 @@ import code.transactionRequestAttribute.TransactionRequestAttribute import code.transactionStatusScheduler.TransactionRequestStatusScheduler import code.transaction_types.MappedTransactionType import code.transactionattribute.MappedTransactionAttribute -import code.bankconnectors.opencorridor.OpenCorridorBankBroker +import code.bankconnectors.opencorridor.{OpenCorridorBankBroker, OpenCorridorOutbox, OpenCorridorOutboxRelay} import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons} import code.usercustomerlinks.MappedUserCustomerLink import code.customerlinks.CustomerLink @@ -561,6 +561,11 @@ class Boot extends MdcLoggable { val delay = APIUtil.getPropsAsLongValue("transaction_request_status_scheduler_delay").openOrThrowException("Incorrect value for transaction_request_status_scheduler_delay, please provide number of seconds.") TransactionRequestStatusScheduler.start(delay) } + // Open Corridor: the transactional-outbox relay publishing Interface C messages + // (credit notifications + settlement instructions) to the banks' own vhosts. + if (APIUtil.getPropsAsBoolValue("open_corridor_enabled", false)) { + OpenCorridorOutboxRelay.start(APIUtil.getPropsAsLongValue("open_corridor.outbox_relay_interval", 10L)) + } APIUtil.getPropsAsLongValue("database_messages_scheduler_interval") match { case Full(i) => DatabaseDriverScheduler.start(i) case _ => // Do not start it @@ -990,6 +995,7 @@ object ToSchemify extends MdcLoggable { MappedTransactionRequest, TransactionRequestAttribute, OpenCorridorBankBroker, + OpenCorridorOutbox, MappedMetric, MetricArchive, MetricsArchiveRun, diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 64cad45705..e1de008909 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -217,6 +217,11 @@ object ApiRole extends MdcLoggable{ case class CanConfigureOpenCorridorBroker(requiresBankId: Boolean = false) extends ApiRole lazy val canConfigureOpenCorridorBroker = CanConfigureOpenCorridorBroker() + // Open Corridor: operator role for the settle-pair trigger — nets a bank pair's + // PENDING promises, posts the net Transaction and enqueues the Interface C messages. + case class CanSettleOpenCorridor(requiresBankId: Boolean = false) extends ApiRole + lazy val canSettleOpenCorridor = CanSettleOpenCorridor() + case class CanAddSocialMediaHandle(requiresBankId: Boolean = true) extends ApiRole lazy val canAddSocialMediaHandle = CanAddSocialMediaHandle() diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index c91f721891..a34e402eca 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -891,6 +891,8 @@ object ErrorMessages { val OpenCorridorPromiseEvidenceConflict = "OBP-40053: Open Corridor promise evidence is already attached to this Transaction Request with different values. Evidence cannot be overwritten." val OpenCorridorBankBrokerNotConfigured = "OBP-40054: No Open Corridor broker is configured for this bank. Register the bank's RabbitMQ coordinates first." val OpenCorridorPublishFailed = "OBP-40055: Could not publish the Open Corridor message to the bank's broker or no reply arrived in time." + val OpenCorridorSettlementAddressMissing = "OBP-40056: The creditor bank has no settlement address registered in its Open Corridor broker registration, so the settlement instruction cannot be addressed." + val OpenCorridorDisabled = "OBP-40057: Open Corridor is not enabled on this API instance. Set open_corridor_enabled=true in the props." // Exceptions (OBP-50XXX) val UnknownError = "OBP-50000: Unknown Error." val FutureTimeoutException = "OBP-50001: Future Timeout Exception." 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 a82de8c16b..698bb65460 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 @@ -8,7 +8,7 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureOpenCorridorBroker, canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} +import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureOpenCorridorBroker, canSettleOpenCorridor, canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.CommonsEmailWrapper import code.model.dataAccess.{AuthUser, MappedBank, ResourceUser} import code.consent.Consents @@ -3527,12 +3527,14 @@ object Http4s700 { } broker <- scala.concurrent.Future { code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( - bank.bankId.value, body.host, body.port, body.virtual_host, body.username, body.password, body.use_ssl + bank.bankId.value, body.host, body.port, body.virtual_host, body.username, body.password, body.use_ssl, + body.settlement_address ) } } yield JSONFactory700.OpenCorridorBankBrokerJsonV700( bank_id = broker.bankId, host = broker.host, port = broker.port, - virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl + virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl, + settlement_address = broker.settlementAddress ) } } @@ -3545,7 +3547,8 @@ object Http4s700 { case net.liftweb.common.Full(broker) => JSONFactory700.OpenCorridorBankBrokerJsonV700( bank_id = broker.bankId, host = broker.host, port = broker.port, - virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl + virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl, + settlement_address = broker.settlementAddress ) case _ => throw new RuntimeException(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: ${bank.bankId.value}") @@ -3569,7 +3572,8 @@ object Http4s700 { virtual_host = "/bank.gh.29.uk", username = "obp-api", password = "***", - use_ssl = false + use_ssl = false, + settlement_address = "addr_test1vqn7sn79x6k9a2353l458mk2gccmwqk7nza93zydpuvl7lquy6jcl" ) val openCorridorBrokerResponseExample = JSONFactory700.OpenCorridorBankBrokerJsonV700( bank_id = "gh.29.uk", @@ -3577,7 +3581,8 @@ object Http4s700 { port = 5672, virtual_host = "/bank.gh.29.uk", username = "obp-api", - use_ssl = false + use_ssl = false, + settlement_address = "addr_test1vqn7sn79x6k9a2353l458mk2gccmwqk7nza93zydpuvl7lquy6jcl" ) resourceDocs += ResourceDoc( @@ -3635,6 +3640,70 @@ object Http4s700 { http4sPartialFunction = Some(deleteOpenCorridorBankBroker) ) + // ── OPEN_CORRIDOR settle-pair (the netting trigger) ─────────────────────── + // Bilateral settle-on-demand: nets the pair's PENDING OPEN_CORRIDOR_PROMISE + // TRs (SUM(A→B) − SUM(B→A)), posts ONE net Transaction between the pair's + // settlement accounts via an internal OPEN_CORRIDOR_SETTLEMENT TR, discharges + // the covered promises, and enqueues the Interface C messages in the same DB + // transaction (transactional outbox; the relay publishes them). + val settleOpenCorridorPair: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "open-corridor" / "settle" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.PostOpenCorridorSettleJsonV700, JSONFactory700.OpenCorridorSettleResultJsonV700](req) { (user, body, cc) => + for { + _ <- code.util.Helper.booleanToFuture(OpenCorridorDisabled, cc = Some(cc)) { + APIUtil.getPropsAsBoolValue("open_corridor_enabled", false) + } + _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue bank_id_a, bank_id_b and currency must be non-empty and the banks must differ", cc = Some(cc)) { + body.bank_id_a.trim.nonEmpty && body.bank_id_b.trim.nonEmpty && + body.currency.trim.nonEmpty && body.bank_id_a != body.bank_id_b + } + (_, _) <- NewStyle.function.getBank(BankId(body.bank_id_a), Some(cc)) + (_, _) <- NewStyle.function.getBank(BankId(body.bank_id_b), Some(cc)) + (result, _) <- code.bankconnectors.opencorridor.OpenCorridorSettlement.settlePair( + user, body.bank_id_a, body.bank_id_b, body.currency, Some(cc)) + } yield result + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(settleOpenCorridorPair), + "POST", + "/open-corridor/settle", + "Settle Open Corridor Pair", + """Trigger bilateral Open Corridor netting for a bank pair and currency. + | + |Computes `net = SUM(PENDING A→B promises) − SUM(PENDING B→A promises)`, mints one internal OPEN_CORRIDOR_SETTLEMENT Transaction Request between the pair's settlement accounts whose execution posts ONE net Transaction (debtor's outgoing settlement account → creditor's incoming), records that Transaction's id on each covered promise in the `settled_by_transaction_ids` attribute (and the settlement TR's id in `settled_by_transaction_request_id`), and sets the covered promises to COMPLETED. N promises collapse into one settlement — that compression is the netting. + | + |In the same database transaction, the Interface C messages are written to the transactional outbox: one `obp_credit_notification` per covered promise to its beneficiary bank (relaying the commit–reveal evidence triplet), and one `obp_settlement_instruction` for the net amount to the debtor bank. The outbox relay publishes them and records each bank's reply. + | + |NOTE: the posted net Transaction deliberately does not mirror any single covered promise — it can differ in direction, amount and accounts. Reconciliation must follow the `settled_by_transaction_ids` linkage, never assume the Transaction matches the promise body. + | + |A trigger for a pair with no PENDING promises is a no-op. When the flows offset exactly (net zero) the promises are discharged with no Transaction posted and no settlement instruction sent — the credit notifications still go out. + | + |Requires `open_corridor_enabled=true` on this instance. + | + |Authentication is Required.""".stripMargin, + JSONFactory700.PostOpenCorridorSettleJsonV700(bank_id_a = "gh.29.uk", bank_id_b = "ke.01.kcs", currency = "KES"), + JSONFactory700.OpenCorridorSettleResultJsonV700( + settlement_id = "6bb27397-6c9b-4c5c-b28f-b19f26d1c6f4", + settlement_transaction_request_id = "6bb27397-6c9b-4c5c-b28f-b19f26d1c6f4", + transaction_id = "902ba3bb-dedd-45e7-9319-2fd3f2cd98a1", + debtor_bank_id = "gh.29.uk", + creditor_bank_id = "ke.01.kcs", + currency = "KES", + net_amount = "2500.00", + covered_transaction_request_ids = List("4050046c-63b3-4868-8a22-14b4181d33a6"), + credit_notifications_enqueued = 3, + settlement_instructions_enqueued = 1 + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, OpenCorridorDisabled, InvalidJsonFormat, InvalidJsonValue, + $BankNotFound, OpenCorridorBankBrokerNotConfigured, OpenCorridorSettlementAddressMissing, UnknownError), + apiTagTransactionRequest :: Nil, + Some(List(canSettleOpenCorridor)), + http4sPartialFunction = Some(settleOpenCorridorPair) + ) + // ── End OPEN_CORRIDOR_PROMISE ───────────────────────────────────────────── // ── BULK transaction request ────────────────────────────────────────────── 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 66ebe67eca..bd67968653 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 @@ -1121,7 +1121,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { virtual_host: String, username: String, password: String, - use_ssl: Boolean + use_ssl: Boolean, + settlement_address: String ) // The password is write-only: never echoed on any response. @@ -1131,7 +1132,39 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { port: Int, virtual_host: String, username: String, - use_ssl: Boolean + use_ssl: Boolean, + settlement_address: String + ) + + // ─── OPEN_CORRIDOR settle-pair ───────────────────────────────────────────── + + case class PostOpenCorridorSettleJsonV700( + bank_id_a: String, + bank_id_b: String, + currency: String + ) + + /** + * Result of a settle-pair run. `transaction_id` is empty when the pair's flows + * offset exactly (net zero: promises are discharged, nothing moves) and when + * `covered_transaction_request_ids` is empty (no-op: nothing was pending). + * NOTE: the posted net Transaction deliberately does NOT match any single + * promise — it is the offset difference, between the settlement accounts, + * possibly opposite in direction to a given covered promise. Reconciliation + * must use `settled_by_transaction_ids` on each promise, never assume the + * Transaction mirrors the promise body. + */ + case class OpenCorridorSettleResultJsonV700( + settlement_id: String, + settlement_transaction_request_id: String, + transaction_id: String, + debtor_bank_id: String, + creditor_bank_id: String, + currency: String, + net_amount: String, + covered_transaction_request_ids: List[String], + credit_notifications_enqueued: Int, + settlement_instructions_enqueued: Int ) // Build the originator block for a TR response. Returns None when there's no diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 1137ba5926..c94c30b2f2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1632,19 +1632,28 @@ object LocalMappedConnector extends Connector with MdcLoggable { callContext: Option[CallContext] ): OBPReturnType[Box[CounterpartyTrait]] = Future { - lazy val counterpartyFromRoutings= Counterparties.counterparties.vend.getCounterpartyByRoutings( - otherBankRoutingScheme: String, - otherBankRoutingAddress: String, - otherBranchRoutingScheme: String, - otherBranchRoutingAddress: String, - otherAccountRoutingScheme: String, - otherAccountRoutingAddress: String - ) - - lazy val counterpartyFromSecondaryRouting = Counterparties.counterparties.vend.getCounterpartyBySecondaryRouting( - otherAccountSecondaryRoutingScheme: String, - otherAccountSecondaryRoutingAddress: String - ) + // Empty routing values must never be used as a lookup key: matching on ("", "") + // returns an arbitrary counterparty that happens to have the field empty — the + // caller would silently get a beneficiary pointing at the wrong bank/account. + lazy val counterpartyFromRoutings = + if (otherAccountRoutingScheme.trim.nonEmpty && otherAccountRoutingAddress.trim.nonEmpty) + Counterparties.counterparties.vend.getCounterpartyByRoutings( + otherBankRoutingScheme: String, + otherBankRoutingAddress: String, + otherBranchRoutingScheme: String, + otherBranchRoutingAddress: String, + otherAccountRoutingScheme: String, + otherAccountRoutingAddress: String + ) + else Empty + + lazy val counterpartyFromSecondaryRouting = + if (otherAccountSecondaryRoutingScheme.trim.nonEmpty && otherAccountSecondaryRoutingAddress.trim.nonEmpty) + Counterparties.counterparties.vend.getCounterpartyBySecondaryRouting( + otherAccountSecondaryRoutingScheme: String, + otherAccountSecondaryRoutingAddress: String + ) + else Empty if(counterpartyFromRoutings.isDefined) { diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala index cdab7569da..eb86f4c114 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala @@ -27,6 +27,11 @@ class OpenCorridorBankBroker extends LongKeyedMapper[OpenCorridorBankBroker] wit object UseSsl extends MappedBoolean(this) { override def defaultValue = false } + /** The bank's settlement-rail (e.g. Cardano bech32) receiving address. Used as + * `creditor_address` in `obp_settlement_instruction` when this bank is the + * creditor of a netted settle (open decision §8.5 of the publish plan: the + * onboarding row carries it, not an account attribute). */ + object SettlementAddress extends MappedString(this, 255) def bankId: String = BankId.get def host: String = Host.get @@ -35,6 +40,7 @@ class OpenCorridorBankBroker extends LongKeyedMapper[OpenCorridorBankBroker] wit def username: String = Username.get def password: String = Password.get def useSsl: Boolean = UseSsl.get + def settlementAddress: String = SettlementAddress.get } object OpenCorridorBankBroker extends OpenCorridorBankBroker with LongKeyedMetaMapper[OpenCorridorBankBroker] { @@ -51,7 +57,8 @@ object OpenCorridorBankBroker extends OpenCorridorBankBroker with LongKeyedMetaM virtualHost: String, username: String, password: String, - useSsl: Boolean + useSsl: Boolean, + settlementAddress: String ): OpenCorridorBankBroker = { val row = findByBankId(bankId).getOrElse(OpenCorridorBankBroker.create.BankId(bankId)) row @@ -61,6 +68,7 @@ object OpenCorridorBankBroker extends OpenCorridorBankBroker with LongKeyedMetaM .Username(username) .Password(password) .UseSsl(useSsl) + .SettlementAddress(settlementAddress) .saveMe() } diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala new file mode 100644 index 0000000000..67c758f89a --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala @@ -0,0 +1,80 @@ +package code.bankconnectors.opencorridor + +import net.liftweb.mapper._ + +/** + * Transactional outbox for Open Corridor Interface C messages. + * + * The settle-pair step commits money movement (the net Transaction, the promise + * discharges) in one DB transaction; the RabbitMQ publishes must survive a crash + * between that commit and the publish. So the outbound messages are written as + * rows here in the SAME transaction, and `OpenCorridorOutboxRelay` publishes + * them afterwards, recording each reply. Publish-after-commit without this + * outbox would lose credit notifications and settlement instructions on a crash + * — with real money that is not acceptable. The Bank Node side is + * idempotent-friendly (`idempotency_key`, evidence upserts), so redelivery from + * here is always safe. + * + * Row lifecycle: + * PENDING — not yet delivered; the relay keeps publishing with backoff. + * For `obp_settlement_instruction` a reply of SUBMITTED/SETTLING + * keeps the row PENDING deliberately: redelivery doubles as the + * SUBMITTED → FINAL status poll (locked wire contract §4.4). + * DELIVERED — the bank replied success (credit notification acked, or + * settlement reported FINAL). + * STICKY — the bank replied with a business error that retrying cannot fix + * (e.g. COMMITMENT-MISMATCH). Needs operator reconciliation; the + * error and full reply are on the row. Never silently swallowed. + */ +class OpenCorridorOutbox extends LongKeyedMapper[OpenCorridorOutbox] with IdPK with CreatedUpdated { + def getSingleton = OpenCorridorOutbox + + /** The settle event this message belongs to (TR B's transaction request id). */ + object SettlementId extends MappedString(this, 64) + /** AMQP messageId: obp_credit_notification / obp_settlement_instruction. */ + object MessageId extends MappedString(this, 64) + /** The bank whose vhost this message is published to. */ + object TargetBankId extends MappedString(this, 255) + /** The flat lower_snake_case wire body, serialized at settle time. */ + object PayloadJson extends MappedText(this) + object Status extends MappedString(this, 16) { + override def defaultValue = OpenCorridorOutbox.STATUS_PENDING + } + object Attempts extends MappedInt(this) { + override def defaultValue = 0 + } + object LastError extends MappedString(this, 2000) + /** The bank's last §4.2 reply envelope, verbatim, for audit/reconciliation. */ + object LastReplyJson extends MappedText(this) + + def settlementId: String = SettlementId.get + def messageId: String = MessageId.get + def targetBankId: String = TargetBankId.get + def payloadJson: String = PayloadJson.get + def status: String = Status.get + def attempts: Int = Attempts.get +} + +object OpenCorridorOutbox extends OpenCorridorOutbox with LongKeyedMetaMapper[OpenCorridorOutbox] { + val STATUS_PENDING = "PENDING" + val STATUS_DELIVERED = "DELIVERED" + val STATUS_STICKY = "STICKY" + + override def dbIndexes: List[BaseIndex[OpenCorridorOutbox]] = + Index(Status) :: Index(SettlementId) :: super.dbIndexes + + def enqueue(settlementId: String, messageId: String, targetBankId: String, payloadJson: String): OpenCorridorOutbox = + OpenCorridorOutbox.create + .SettlementId(settlementId) + .MessageId(messageId) + .TargetBankId(targetBankId) + .PayloadJson(payloadJson) + .Status(STATUS_PENDING) + .saveMe() + + def pending(): List[OpenCorridorOutbox] = + OpenCorridorOutbox.findAll(By(OpenCorridorOutbox.Status, STATUS_PENDING)) + + def bySettlementId(settlementId: String): List[OpenCorridorOutbox] = + OpenCorridorOutbox.findAll(By(OpenCorridorOutbox.SettlementId, settlementId)) +} diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala new file mode 100644 index 0000000000..81c5ef7008 --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala @@ -0,0 +1,129 @@ +package code.bankconnectors.opencorridor + +import code.actorsystem.ObpActorSystem +import code.util.Helper.MdcLoggable +import net.liftweb.common.{Box, Failure, Full} +import org.json4s._ +import org.json4s.native.Serialization + +import java.util.concurrent.TimeUnit +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Publishes Open Corridor outbox rows to the target banks' vhosts and records + * the replies. Runs on the actor-system scheduler (started from Boot when + * `open_corridor_enabled=true`), one pass per tick, rows processed serially — + * throughput is not the concern here, at-least-once delivery with a recorded + * audit trail is. + * + * Reply handling (locked wire contract §4.2/§4.4): + * - transport failure / timeout / broker unregistered → row stays PENDING, + * attempts+1 (retried next tick; exponential backoff by attempts). + * - errorCode == "" on a credit notification → DELIVERED. + * - errorCode == "" on a settlement instruction → DELIVERED only when the + * bank reports status FINAL; SUBMITTED / SETTLING keep the row PENDING — + * redelivery IS the status poll, and the Bank Node never pays twice for the + * same idempotency_key. + * - OBP-BANK-NODE-SETTLEMENT-FAILED → stays PENDING: the node allows a retry + * when the failure provably never reached the chain, and repeats the + * recorded error otherwise, so redelivery is safe and the error stays + * visible on the row either way. + * - any other OBP-BANK-NODE-* error (COMMITMENT-MISMATCH, CBS-DELIVERY-FAILED, + * BAD-MESSAGE, NOT-IMPLEMENTED, SETTLEMENT-NOT-CONFIGURED) → STICKY: retry + * cannot fix it; it needs an operator. The error + full reply are recorded — + * never swallowed. + */ +object OpenCorridorOutboxRelay extends MdcLoggable { + + private implicit val formats = code.api.util.CustomJsonFormats.nullTolerateFormats + + /** Base backoff between attempts for a row; doubles per attempt, capped. */ + private val baseBackoff = 10.seconds + private val maxBackoff = 10.minutes + /** Cap on how long one row's publish may block the (serial) relay pass. */ + private val perRowTimeout = 60.seconds + + private val stickyErrorCodes = Set( + "OBP-BANK-NODE-COMMITMENT-MISMATCH", + "OBP-BANK-NODE-CBS-DELIVERY-FAILED", + "OBP-BANK-NODE-BAD-MESSAGE", + "OBP-BANK-NODE-NOT-IMPLEMENTED", + "OBP-BANK-NODE-SETTLEMENT-NOT-CONFIGURED" + ) + + def start(intervalSeconds: Long): Unit = { + implicit val executor = ObpActorSystem.localActorSystem.dispatcher + ObpActorSystem.localActorSystem.scheduler.schedule( + initialDelay = scala.concurrent.duration.Duration(intervalSeconds, TimeUnit.SECONDS), + interval = scala.concurrent.duration.Duration(intervalSeconds, TimeUnit.SECONDS), + runnable = new Runnable { + def run(): Unit = + try relayOnePass() + catch { case e: Throwable => logger.error("Open Corridor outbox relay pass failed", e) } + } + ) + logger.info(s"Open Corridor outbox relay started (interval ${intervalSeconds}s)") + } + + /** One pass over the PENDING rows that are due (backoff by attempts). */ + def relayOnePass(): Unit = { + val now = System.currentTimeMillis() + val due = OpenCorridorOutbox.pending().filter { row => + val backoff = (baseBackoff * math.pow(2, math.min(row.attempts, 6)).toLong).min(maxBackoff) + row.updatedAt.get.getTime + backoff.toMillis <= now || row.attempts == 0 + } + if (due.nonEmpty) logger.debug(s"Open Corridor outbox relay: ${due.size} row(s) due") + due.foreach(relayRow) + } + + def relayRow(row: OpenCorridorOutbox): Unit = { + val replyBox: Box[com.openbankproject.commons.dto.InBoundOpenCorridorReply] = + try { + Await.result( + OpenCorridorPublisher.publishRawAndAwaitReply(row.targetBankId, row.messageId, row.payloadJson), + perRowTimeout + ) + } catch { + case e: Throwable => Failure(s"publish await failed: ${e.getMessage}") + } + + replyBox match { + case Full(reply) => + val replyJson = Serialization.write(reply) + val errorCode = reply.status.errorCode + if (errorCode.isEmpty) { + val settlementStatus = + if (row.messageId == "obp_settlement_instruction") + (reply.data \ "status").extractOpt[String].getOrElse("") + else "" + if (row.messageId == "obp_settlement_instruction" && settlementStatus != "FINAL") { + // Broadcast but not final — keep polling by redelivery (§4.4). + row.Attempts(row.attempts + 1).LastError("").LastReplyJson(replyJson).saveMe() + logger.info(s"Open Corridor outbox row ${row.id.get}: settlement ${row.settlementId} status '$settlementStatus' — will re-poll") + } else { + row.Status(OpenCorridorOutbox.STATUS_DELIVERED).LastError("").LastReplyJson(replyJson).saveMe() + logger.info(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} DELIVERED") + } + } else if (stickyErrorCodes.exists(errorCode.startsWith)) { + row.Status(OpenCorridorOutbox.STATUS_STICKY).Attempts(row.attempts + 1) + .LastError(errorCode).LastReplyJson(replyJson).saveMe() + logger.error(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} " + + s"STICKY error $errorCode — operator reconciliation required (settlement ${row.settlementId})") + } else { + // Retryable business failure (e.g. SETTLEMENT-FAILED) — keep redelivering. + row.Attempts(row.attempts + 1).LastError(errorCode).LastReplyJson(replyJson).saveMe() + logger.warn(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} " + + s"replied $errorCode — will retry") + } + case failure => + val error = failure match { + case Failure(msg, _, _) => msg + case _ => "no reply" + } + row.Attempts(row.attempts + 1).LastError(error.take(2000)).saveMe() + logger.warn(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} " + + s"transport failure (attempt ${row.attempts}): $error") + } + } +} diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala index cafdf1d7f4..1974e7fd2d 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala @@ -89,9 +89,13 @@ object OpenCorridorPublisher extends MdcLoggable { * parsing — callers must handle non-empty error codes explicitly (they must * never be swallowed). */ - def publishAndAwaitReply(bankId: String, messageId: String, wireBody: AnyRef): Future[Box[InBoundOpenCorridorReply]] = { + def publishAndAwaitReply(bankId: String, messageId: String, wireBody: AnyRef): Future[Box[InBoundOpenCorridorReply]] = + publishRawAndAwaitReply(bankId, messageId, write(wireBody)) + + /** Same, but with an already-serialized wire body (the outbox stores payloads as JSON). */ + def publishRawAndAwaitReply(bankId: String, messageId: String, bodyJson: String): Future[Box[InBoundOpenCorridorReply]] = { OpenCorridorBankBroker.findByBankId(bankId) match { - case Full(broker) => publishToBroker(broker, messageId, write(wireBody)) + case Full(broker) => publishToBroker(broker, messageId, bodyJson) case _ => Future.successful(Failure(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankId")) } } diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala new file mode 100644 index 0000000000..1b09d06065 --- /dev/null +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala @@ -0,0 +1,292 @@ +package code.bankconnectors.opencorridor + +import code.api.Constant.{INCOMING_SETTLEMENT_ACCOUNT_ID, OUTGOING_SETTLEMENT_ACCOUNT_ID} +import code.api.util.APIUtil.generateUUID +import code.api.util.ErrorMessages._ +import code.api.util.{CallContext, NewStyle} +import code.api.v7_0_0.JSONFactory700.OpenCorridorSettleResultJsonV700 +import code.bankconnectors.DoobieTransactionRequestQueries +import code.transactionrequests.{MappedTransactionRequest, TransactionRequests} +import code.util.Helper +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.dto._ +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.{TransactionRequestAttributeType, TransactionRequestStatus, TransactionRequestTypes} +import net.liftweb.common.Full +import net.liftweb.mapper.By +import org.json4s.NoTypeHints +import org.json4s.native.Serialization + +import scala.concurrent.Future + +/** + * The Open Corridor settle-pair step (OPEN_CORRIDOR_SIMPLE_NETTING.md §4/§6.4, + * publish plan §5.3): bilateral, settle-on-demand netting. + * + * net = SUM(PENDING A→B promises) − SUM(PENDING B→A promises) + * + * One internal OPEN_CORRIDOR_SETTLEMENT TransactionRequest ("TR B") is minted + * between the pair's settlement accounts and executed, posting ONE Transaction + * for abs(net) (debtor's OUTGOING settlement account → creditor's INCOMING). + * Every covered promise TR gets: + * - `settled_by_transaction_ids` attribute = the net Transaction's id (only + * when a Transaction posted — a zero net discharges without one), + * - `settled_by_transaction_request_id` attribute = TR B's id (always, so the + * settle event is traceable even at net zero), + * and flips PENDING → COMPLETED. + * + * The outbound Interface C messages (credit notification per covered promise to + * its beneficiary bank, the net settlement instruction to the debtor) are + * written to the OpenCorridorOutbox in the SAME request DB transaction — the + * ResourceDocMiddleware transaction wrapper makes the whole settle atomic: a + * crash rolls back money movement and outbox rows together. + * + * Idempotency / concurrency: every covered promise TR row is row-locked + * (SELECT ... FOR UPDATE via DoobieTransactionRequestQueries) and its status + * re-read under the lock, so a concurrent double-trigger cannot settle the same + * promises twice; a re-trigger with nothing PENDING is a no-op. TR B's id is + * the stable settlement_id and the Bank Node `idempotency_key`. + */ +object OpenCorridorSettlement extends MdcLoggable { + + val AttrSettledByTransactionIds = "settled_by_transaction_ids" + val AttrSettledByTransactionRequestId = "settled_by_transaction_request_id" + + private implicit val wireFormats = Serialization.formats(NoTypeHints) + + def settlePair( + user: User, + bankIdA: String, + bankIdB: String, + currency: String, + callContext: Option[CallContext] + ): Future[(OpenCorridorSettleResultJsonV700, Option[CallContext])] = { + + def pendingPromises(fromBank: String, toBank: String): List[MappedTransactionRequest] = + MappedTransactionRequest.findAll( + By(MappedTransactionRequest.mType, TransactionRequestTypes.OPEN_CORRIDOR_PROMISE.toString), + By(MappedTransactionRequest.mStatus, TransactionRequestStatus.PENDING.toString), + By(MappedTransactionRequest.mFrom_BankId, fromBank), + By(MappedTransactionRequest.mTo_BankId, toBank), + By(MappedTransactionRequest.mBody_Value_Currency, currency) + ) + + for { + // Candidate discovery, then row-lock each candidate and re-read its status + // under the lock — a concurrent settle may have completed it in between. + candidates <- Future { + pendingPromises(bankIdA, bankIdB) ++ pendingPromises(bankIdB, bankIdA) + } + covered <- Future { + candidates.flatMap { row => + val trId = row.mTransactionRequestId.get + if (DoobieTransactionRequestQueries.lockTransactionRequest(trId).isEmpty) { + logger.warn(s"Open Corridor settle: could not lock promise TR $trId — skipping") + None + } else { + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, trId)) + .filter(_.mStatus.get == TransactionRequestStatus.PENDING.toString) + } + } + } + result <- + if (covered.isEmpty) { + // No-op re-trigger: nothing pending for the pair. + Future.successful((OpenCorridorSettleResultJsonV700( + settlement_id = "", + settlement_transaction_request_id = "", + transaction_id = "", + debtor_bank_id = "", + creditor_bank_id = "", + currency = currency, + net_amount = "0", + covered_transaction_request_ids = Nil, + credit_notifications_enqueued = 0, + settlement_instructions_enqueued = 0 + ), callContext)) + } else { + executeSettle(user, bankIdA, bankIdB, currency, covered, callContext) + } + } yield result + } + + private def executeSettle( + user: User, + bankIdA: String, + bankIdB: String, + currency: String, + covered: List[MappedTransactionRequest], + callContext: Option[CallContext] + ): Future[(OpenCorridorSettleResultJsonV700, Option[CallContext])] = { + + val aToB = covered.filter(_.mFrom_BankId.get == bankIdA) + val bToA = covered.filter(_.mFrom_BankId.get == bankIdB) + + val sumAToB = aToB.map(row => BigDecimal(row.mBody_Value_Amount.get)).sum + val sumBToA = bToA.map(row => BigDecimal(row.mBody_Value_Amount.get)).sum + val net = sumAToB - sumBToA + + val (debtorBankId, creditorBankId) = if (net >= 0) (bankIdA, bankIdB) else (bankIdB, bankIdA) + val netAbs = net.abs + + for { + // Fail fast BEFORE mutating anything: both banks need a broker registration + // (credit notifications go to each beneficiary's vhost), and a non-zero net + // needs the creditor's settlement address for the instruction. + _ <- Helper.booleanToFuture(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankIdA", cc = callContext) { + OpenCorridorBankBroker.findByBankId(bankIdA).isDefined + } + _ <- Helper.booleanToFuture(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankIdB", cc = callContext) { + OpenCorridorBankBroker.findByBankId(bankIdB).isDefined + } + creditorSettlementAddress = OpenCorridorBankBroker.findByBankId(creditorBankId) + .map(_.settlementAddress).getOrElse("") + _ <- Helper.booleanToFuture(s"$OpenCorridorSettlementAddressMissing BANK_ID: $creditorBankId", cc = callContext) { + netAbs == 0 || creditorSettlementAddress.trim.nonEmpty + } + + // The settlement accounts (created at boot for every bank). + (debtorOutgoing, callContext) <- NewStyle.function.getBankAccount( + BankId(debtorBankId), AccountId(OUTGOING_SETTLEMENT_ACCOUNT_ID), callContext) + (creditorIncoming, callContext) <- NewStyle.function.getBankAccount( + BankId(creditorBankId), AccountId(INCOMING_SETTLEMENT_ACCOUNT_ID), callContext) + + // Mint TR B — the settle event's audit object and the settlement_id. + settlementTrId = generateUUID() + commonBody = TransactionRequestCommonBodyJSONCommons( + AmountOfMoneyJsonV121(currency, netAbs.toString()), + s"Open Corridor net settlement $debtorBankId -> $creditorBankId ($currency), covering ${covered.size} promise(s)" + ) + settlementTr <- Future { + TransactionRequests.transactionRequestProvider.vend.createTransactionRequestImpl210( + TransactionRequestId(settlementTrId), + TransactionRequestType(TransactionRequestTypes.OPEN_CORRIDOR_SETTLEMENT.toString), + debtorOutgoing, + creditorIncoming, + commonBody, + Serialization.write(commonBody), + TransactionRequestStatus.PENDING.toString, + TransactionRequestCharge("Open Corridor settlement", AmountOfMoney(currency, "0.00")), + "SHARED", + None, None, None, None, + callContext + ) + } map { + _.toOption.getOrElse( + throw new RuntimeException(s"$UnknownError could not create the OPEN_CORRIDOR_SETTLEMENT Transaction Request")) + } + + // Execute TR B: post the ONE net Transaction (unless the net is zero — + // flows offset exactly, promises discharge with nothing moving). + netTransactionId <- + if (netAbs > 0) { + for { + (transactionId, _) <- NewStyle.function.makePaymentv210( + debtorOutgoing, + creditorIncoming, + TransactionRequestId(settlementTrId), + commonBody, + netAbs, + commonBody.description, + TransactionRequestType(TransactionRequestTypes.OPEN_CORRIDOR_SETTLEMENT.toString), + "SHARED", + callContext + ) + _ <- Future(TransactionRequests.transactionRequestProvider.vend + .saveTransactionRequestTransactionImpl(TransactionRequestId(settlementTrId), transactionId)) + } yield transactionId.value + } else Future.successful("") + _ <- Future(TransactionRequests.transactionRequestProvider.vend + .saveTransactionRequestStatusImpl(TransactionRequestId(settlementTrId), TransactionRequestStatus.COMPLETED.toString)) + + // Discharge every covered promise: linkage attributes + PENDING → COMPLETED. + _ <- Future.sequence(covered.map { row => + val promiseTrId = TransactionRequestId(row.mTransactionRequestId.get) + val linkage = + TransactionRequestAttributeJsonV400(AttrSettledByTransactionRequestId, TransactionRequestAttributeType.STRING.toString, settlementTrId) :: + (if (netTransactionId.nonEmpty) + List(TransactionRequestAttributeJsonV400(AttrSettledByTransactionIds, TransactionRequestAttributeType.STRING.toString, netTransactionId)) + else Nil) + for { + (_, _) <- NewStyle.function.createTransactionRequestAttributes( + BankId(row.mFrom_BankId.get), promiseTrId, linkage, isPersonal = false, callContext) + _ <- Future(TransactionRequests.transactionRequestProvider.vend + .saveTransactionRequestStatusImpl(promiseTrId, TransactionRequestStatus.COMPLETED.toString)) + } yield () + }) + + // Enqueue the Interface C messages in this same DB transaction (the outbox). + creditNotifications <- Future.sequence(covered.map(row => buildCreditNotification(row, callContext))) + _ <- Future { + creditNotifications.foreach { case (beneficiaryBankId, wireBody) => + OpenCorridorOutbox.enqueue( + settlementTrId, "obp_credit_notification", beneficiaryBankId, Serialization.write(wireBody)) + } + } + settlementInstructionCount <- Future { + if (netAbs > 0) { + val instruction = OutBoundOpenCorridorSettlementInstruction( + snapshot_id = None, + settlement_id = settlementTrId, + settlement_system = code.api.util.APIUtil.getPropsValue("open_corridor.settlement_system", "cardano-ada"), + currency = currency, + amount = netAbs.toString(), + creditor_bank_id = creditorBankId, + creditor_address = creditorSettlementAddress, + idempotency_key = settlementTrId + ) + OpenCorridorOutbox.enqueue( + settlementTrId, "obp_settlement_instruction", debtorBankId, Serialization.write(instruction)) + 1 + } else 0 + } + } yield { + logger.info(s"Open Corridor settled pair ($bankIdA, $bankIdB) $currency: net $net, " + + s"debtor $debtorBankId, creditor $creditorBankId, ${covered.size} promise(s), " + + s"settlement TR $settlementTrId, transaction '${netTransactionId}'") + (OpenCorridorSettleResultJsonV700( + settlement_id = settlementTrId, + settlement_transaction_request_id = settlementTrId, + transaction_id = netTransactionId, + debtor_bank_id = debtorBankId, + creditor_bank_id = creditorBankId, + currency = currency, + net_amount = netAbs.toString(), + covered_transaction_request_ids = covered.map(_.mTransactionRequestId.get), + credit_notifications_enqueued = creditNotifications.size, + settlement_instructions_enqueued = settlementInstructionCount + ), callContext) + } + } + + /** Build the credit notification for one covered promise, addressed to its + * beneficiary (to-side) bank, relaying the §5.1 evidence attributes verbatim. */ + private def buildCreditNotification( + row: MappedTransactionRequest, + callContext: Option[CallContext] + ): Future[(String, OutBoundOpenCorridorCreditNotification)] = { + val promiseTrId = TransactionRequestId(row.mTransactionRequestId.get) + for { + (attributes, _) <- NewStyle.function.getTransactionRequestAttributes( + BankId(row.mFrom_BankId.get), promiseTrId, callContext) + } yield { + def attr(name: String): Option[String] = + attributes.find(_.name == name).map(_.value).filter(_.nonEmpty) + val wireBody = OutBoundOpenCorridorCreditNotification( + transaction_request_id = promiseTrId.value, + value = OpenCorridorMoneyValue(row.mBody_Value_Currency.get, row.mBody_Value_Amount.get), + description = Option(row.mBody_Description.get).filter(_.nonEmpty), + originator = Option(row.mOriginator_Name.get).filter(_.nonEmpty).map(name => + OpenCorridorOriginator(name, Option(row.mOriginator_Address.get).filter(_.nonEmpty))), + netting_snapshot_id = None, + promise_id = attr(OpenCorridorProcessor.PromiseAttributeTxHash), + promise_blockchain = attr(OpenCorridorProcessor.PromiseAttributeBlockchain), + promise_commitment = attr(OpenCorridorProcessor.PromiseAttributeCommitment), + promise_salt = attr(OpenCorridorProcessor.PromiseAttributeSalt), + promise_preimage = attr(OpenCorridorProcessor.PromiseAttributePreimage) + ) + (row.mTo_BankId.get, wireBody) + } + } +} 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 2589ac1e37..57420da991 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 @@ -7,8 +7,8 @@ import code.api.util.http4s.Http4sStandardHeaders import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, InvalidTransactionRequestId, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureOpenCorridorBroker, canSettleOpenCorridor, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, InvalidTransactionRequestId, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OpenCorridorBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSettlementAddressMissing, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -1634,22 +1634,25 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { private def openCorridorPromiseBody( currency: String, originatorName: String = "Alice Sender", - originatorRoutingAddress: String = "GB29 NWBK 6016 1331 9268 19" + originatorRoutingAddress: String = "GB29 NWBK 6016 1331 9268 19", + amount: String = "3.00", + beneficiaryBankId: String = testBankId2.value, + beneficiaryAccountId: String = testAccountId1.value ): String = s"""{ | "to": { | "name": "OC Beneficiary ${APIUtil.generateUUID().take(8)}", | "description": "Beneficiary at receiving institution", | "other_bank_routing_scheme": "OBP", - | "other_bank_routing_address": "${testBankId2.value}", + | "other_bank_routing_address": "$beneficiaryBankId", | "other_branch_routing_scheme": "", | "other_branch_routing_address": "", | "other_account_routing_scheme": "OBP", - | "other_account_routing_address": "${testAccountId1.value}", + | "other_account_routing_address": "$beneficiaryAccountId", | "other_account_secondary_routing_scheme": "", | "other_account_secondary_routing_address": "" | }, - | "value": {"currency": "$currency", "amount": "3.00"}, + | "value": {"currency": "$currency", "amount": "$amount"}, | "description": "Open Corridor promise test payment", | "charge_policy": "SHARED", | "originator": { @@ -1803,15 +1806,23 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { |}""".stripMargin /** Create an OPEN_CORRIDOR_PROMISE TR via the v7 endpoint; asserts hold-at-PENDING - * and returns the new TRANSACTION_REQUEST_ID. */ - private def createPendingPromise(): String = { + * and returns the new TRANSACTION_REQUEST_ID. Defaults: bank1/account0 promising + * to bank2/account1. */ + private def createPendingPromise( + fromBankId: CommBankId = testBankId1, + fromAccountId: com.openbankproject.commons.model.AccountId = testAccountId0, + beneficiaryBankId: String = testBankId2.value, + beneficiaryAccountId: String = testAccountId1.value, + amount: String = "3.00" + ): String = { val acctCurrency = code.bankconnectors.Connector.connector.vend - .getBankAccountLegacy(testBankId1, testAccountId0, None) + .getBankAccountLegacy(fromBankId, fromAccountId, None) .map(_._1.currency).openOrThrowException("test account") val headers = Map("DirectLogin" -> s"token=${token1.value}") val (statusCode, json, _) = makeHttpRequestWithBody("POST", - openCorridorPromisePath(testBankId1.value, testAccountId0.value), - openCorridorPromiseBody(acctCurrency), headers) + openCorridorPromisePath(fromBankId.value, fromAccountId.value), + openCorridorPromiseBody(acctCurrency, amount = amount, + beneficiaryBankId = beneficiaryBankId, beneficiaryAccountId = beneficiaryAccountId), headers) statusCode shouldBe 201 json match { case JObject(fields) => @@ -1978,6 +1989,294 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── OPEN_CORRIDOR broker registry + settle-pair ────────────────────────── + + private def brokerPath(bankId: String): String = + s"/obp/v7.0.0/banks/$bankId/open-corridor/broker" + + private def brokerBody(settlementAddress: String = "addr_test_creditor"): String = + s"""{ + | "host": "rabbitmq.bank.example.com", + | "port": 5672, + | "virtual_host": "/bank.test", + | "username": "obp-api", + | "password": "secret-not-echoed", + | "use_ssl": false, + | "settlement_address": "$settlementAddress" + |}""".stripMargin + + private def ensureSettlementAccounts(bankId: String, currency: String): Unit = { + import code.model.dataAccess.MappedBankAccount + List(code.api.Constant.INCOMING_SETTLEMENT_ACCOUNT_ID, code.api.Constant.OUTGOING_SETTLEMENT_ACCOUNT_ID).foreach { accountId => + if (MappedBankAccount.find(By(MappedBankAccount.bank, bankId), By(MappedBankAccount.theAccountId, accountId)).isEmpty) { + MappedBankAccount.create.bank(bankId).theAccountId(accountId).accountCurrency(currency).saveMe() + } + } + } + + private def promiseStatus(transactionRequestId: String): String = + code.transactionrequests.TransactionRequests.transactionRequestProvider.vend + .getTransactionRequestFromProvider(com.openbankproject.commons.model.TransactionRequestId(transactionRequestId)) + .map(_.status).openOrThrowException("TR should exist") + + private def promiseAttributes(transactionRequestId: String): Map[String, String] = { + import scala.concurrent.Await + import scala.concurrent.duration._ + Await.result( + code.transactionRequestAttribute.TransactionRequestAttributeX.transactionRequestAttributeProvider.vend + .getTransactionRequestAttributesFromProvider(com.openbankproject.commons.model.TransactionRequestId(transactionRequestId)), + 10.seconds + ).openOrThrowException("attributes should load").map(a => a.name -> a.value).toMap + } + + feature("Http4s700 Open Corridor bank broker registry endpoints") { + + scenario("Reject unauthenticated PUT", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequestWithBody("PUT", brokerPath(testBankId1.value), brokerBody()) + statusCode shouldBe 401 + } + + scenario("Return 403 without CanConfigureOpenCorridorBroker", Http4s700RoutesTag) { + val headers = Map("DirectLogin" -> s"token=${token2.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", brokerPath(testBankId1.value), brokerBody(), headers) + statusCode shouldBe 403 + messageOf(json) should include("CanConfigureOpenCorridorBroker") + } + + scenario("Broker registry CRUD round-trip; password is never echoed", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canConfigureOpenCorridorBroker.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("DELETE clears any previous registration (idempotent)") + val (deleteCode, _, _) = makeHttpRequestWithMethod("DELETE", brokerPath(testBankId1.value), headers) + deleteCode shouldBe 204 + + Then("GET without a registration is refused") + val (missingCode, missingJson, _) = makeHttpRequest(brokerPath(testBankId1.value), headers) + missingCode shouldBe 400 + messageOf(missingJson) should include(OpenCorridorBankBrokerNotConfigured) + + When("PUT registers the broker") + val (putCode, putJson, _) = makeHttpRequestWithBody("PUT", brokerPath(testBankId1.value), brokerBody(), headers) + putCode shouldBe 200 + putJson match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("bank_id") shouldBe Some(JString(testBankId1.value)) + map.get("host") shouldBe Some(JString("rabbitmq.bank.example.com")) + map.get("settlement_address") shouldBe Some(JString("addr_test_creditor")) + map.keys should not contain "password" + case _ => fail("Expected JSON object") + } + + Then("GET returns the registration, still without the password") + val (getCode, getJson, _) = makeHttpRequest(brokerPath(testBankId1.value), headers) + getCode shouldBe 200 + getJson match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("virtual_host") shouldBe Some(JString("/bank.test")) + map.keys should not contain "password" + case _ => fail("Expected JSON object") + } + + And("DELETE removes it again") + val (deleteCode2, _, _) = makeHttpRequestWithMethod("DELETE", brokerPath(testBankId1.value), headers) + deleteCode2 shouldBe 204 + val (goneCode, _, _) = makeHttpRequest(brokerPath(testBankId1.value), headers) + goneCode shouldBe 400 + } + } + + feature("Http4s700 settleOpenCorridorPair endpoint (bilateral netting)") { + + def settleBody(currency: String): String = + s"""{"bank_id_a": "${testBankId1.value}", "bank_id_b": "${testBankId2.value}", "currency": "$currency"}""" + + def registerBrokers(): Unit = { + code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( + testBankId1.value, "localhost", 5672, "/bank.a", "u", "p", false, "addr_test_bank_a") + code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( + testBankId2.value, "localhost", 5672, "/bank.b", "u", "p", false, "addr_test_bank_b") + } + + scenario("Reject unauthenticated POST", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/open-corridor/settle", settleBody("EUR")) + statusCode shouldBe 401 + } + + scenario("Return 403 without CanSettleOpenCorridor", Http4s700RoutesTag) { + val headers = Map("DirectLogin" -> s"token=${token2.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/open-corridor/settle", settleBody("EUR"), headers) + statusCode shouldBe 403 + messageOf(json) should include("CanSettleOpenCorridor") + } + + scenario("Return 400 when open_corridor_enabled is not set", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canSettleOpenCorridor.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/open-corridor/settle", settleBody("EUR"), headers) + statusCode shouldBe 400 + messageOf(json) should include(OpenCorridorDisabled) + } + + scenario("Net a pair: N promises collapse into one settlement, evidence relayed via outbox", Http4s700RoutesTag) { + setPropsValues("open_corridor_enabled" -> "true") + addEntitlement("", resourceUser1.userId, canSettleOpenCorridor.toString) + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + Given("Matching currencies, settlement accounts and broker registrations for both banks") + val currency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val currency2 = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId2, testAccountId1, None) + .map(_._1.currency).openOrThrowException("test account") + currency2 shouldBe currency + ensureSettlementAccounts(testBankId1.value, currency) + ensureSettlementAccounts(testBankId2.value, currency) + + And("Three pending promises: A→B 5.00 + 2.00, B→A 3.00") + def assertPromiseRow(trId: String, fromBank: String, toBank: String): Unit = { + val row = code.transactionrequests.MappedTransactionRequest + .find(By(code.transactionrequests.MappedTransactionRequest.mTransactionRequestId, trId)) + .openOrThrowException("promise TR row should exist") + withClue(s"TR $trId row: from=${row.mFrom_BankId.get} to=${row.mTo_BankId.get} " + + s"currency=${row.mBody_Value_Currency.get} status=${row.mStatus.get} type=${row.mType.get} — ") { + row.mFrom_BankId.get shouldBe fromBank + row.mTo_BankId.get shouldBe toBank + row.mBody_Value_Currency.get shouldBe currency + } + } + val promise1 = createPendingPromise(amount = "5.00") + val promise2 = createPendingPromise(amount = "2.00") + val promise3 = createPendingPromise(testBankId2, testAccountId1, testBankId1.value, testAccountId0.value, "3.00") + assertPromiseRow(promise1, testBankId1.value, testBankId2.value) + assertPromiseRow(promise3, testBankId2.value, testBankId1.value) + + And("Promise 1 has its on-chain evidence attached (report-back)") + val (evidenceCode, _, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, promise1), + promiseEvidenceBody(), headers) + evidenceCode shouldBe 201 + + When("Settle is triggered while the creditor bank has no settlement address") + code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( + testBankId1.value, "localhost", 5672, "/bank.a", "u", "p", false, "addr_test_bank_a") + code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( + testBankId2.value, "localhost", 5672, "/bank.b", "u", "p", false, "") + val (noAddressCode, noAddressJson, _) = makeHttpRequestWithBody("POST", + "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + noAddressCode shouldBe 400 + messageOf(noAddressJson) should include(OpenCorridorSettlementAddressMissing) + promiseStatus(promise1) shouldBe "PENDING" + + Then("With both brokers fully registered the settle succeeds") + registerBrokers() + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + statusCode shouldBe 201 + val (settlementId, transactionId) = json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("net_amount") shouldBe Some(JString("4.00")) + map.get("debtor_bank_id") shouldBe Some(JString(testBankId1.value)) + map.get("creditor_bank_id") shouldBe Some(JString(testBankId2.value)) + map.get("credit_notifications_enqueued") shouldBe Some(JInt(3)) + map.get("settlement_instructions_enqueued") shouldBe Some(JInt(1)) + map.get("covered_transaction_request_ids") match { + case Some(JArray(ids)) => ids.collect { case JString(id) => id }.toSet shouldBe Set(promise1, promise2, promise3) + case _ => fail("covered_transaction_request_ids should be an array") + } + val sid = map.get("settlement_id").collect { case JString(s) => s }.getOrElse(fail("settlement_id missing")) + val tid = map.get("transaction_id").collect { case JString(s) => s }.getOrElse(fail("transaction_id missing")) + sid should not be empty + tid should not be empty + (sid, tid) + case _ => fail("Expected JSON object") + } + + And("Every covered promise is COMPLETED with the discharge linkage attributes") + List(promise1, promise2, promise3).foreach { promiseId => + promiseStatus(promiseId) shouldBe "COMPLETED" + val attributes = promiseAttributes(promiseId) + attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionIds) shouldBe Some(transactionId) + attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionRequestId) shouldBe Some(settlementId) + } + + And("The outbox holds 3 credit notifications + 1 settlement instruction, evidence relayed verbatim") + val outboxRows = code.bankconnectors.opencorridor.OpenCorridorOutbox.bySettlementId(settlementId) + outboxRows.size shouldBe 4 + val creditRows = outboxRows.filter(_.messageId == "obp_credit_notification") + creditRows.map(_.targetBankId).sorted shouldBe List(testBankId1.value, testBankId2.value, testBankId2.value).sorted + val instructionRow = outboxRows.filter(_.messageId == "obp_settlement_instruction") match { + case row :: Nil => row + case other => fail(s"Expected exactly one settlement instruction row, got ${other.size}") + } + instructionRow.targetBankId shouldBe testBankId1.value + val instructionJson = parse(instructionRow.payloadJson) + (instructionJson \ "amount") shouldBe JString("4.00") + (instructionJson \ "creditor_address") shouldBe JString("addr_test_bank_b") + (instructionJson \ "idempotency_key") shouldBe JString(settlementId) + val promise1Credit = creditRows.map(row => parse(row.payloadJson)) + .find(payload => (payload \ "transaction_request_id") == JString(promise1)) + .getOrElse(fail("promise1's credit notification should be enqueued")) + (promise1Credit \ "promise_salt") shouldBe JString("5f4dcc3b5aa765d61d8327deb882cf99") + (promise1Credit \ "promise_commitment") shouldBe JString("9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1") + + And("A re-trigger with nothing pending is a no-op") + val (noopCode, noopJson, _) = makeHttpRequestWithBody("POST", + "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + noopCode shouldBe 201 + noopJson match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("covered_transaction_request_ids") shouldBe Some(JArray(Nil)) + map.get("settlement_id") shouldBe Some(JString("")) + case _ => fail("Expected JSON object") + } + } + + scenario("Exactly offsetting flows discharge at net zero with no Transaction", Http4s700RoutesTag) { + setPropsValues("open_corridor_enabled" -> "true") + addEntitlement("", resourceUser1.userId, canSettleOpenCorridor.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val currency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + ensureSettlementAccounts(testBankId1.value, currency) + ensureSettlementAccounts(testBankId2.value, currency) + registerBrokers() + + val promiseAToB = createPendingPromise(amount = "3.00") + val promiseBToA = createPendingPromise(testBankId2, testAccountId1, testBankId1.value, testAccountId0.value, "3.00") + + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + statusCode shouldBe 201 + val settlementId = json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("net_amount") shouldBe Some(JString("0.00")) + map.get("transaction_id") shouldBe Some(JString("")) + map.get("credit_notifications_enqueued") shouldBe Some(JInt(2)) + map.get("settlement_instructions_enqueued") shouldBe Some(JInt(0)) + map.get("settlement_id").collect { case JString(s) => s }.getOrElse(fail("settlement_id missing")) + case _ => fail("Expected JSON object") + } + + List(promiseAToB, promiseBToA).foreach { promiseId => + promiseStatus(promiseId) shouldBe "COMPLETED" + val attributes = promiseAttributes(promiseId) + attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionRequestId) shouldBe Some(settlementId) + attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionIds) shouldBe None + } + code.bankconnectors.opencorridor.OpenCorridorOutbox.bySettlementId(settlementId) + .filter(_.messageId == "obp_settlement_instruction") shouldBe Nil + } + } + // ─── BULK transaction request ───────────────────────────────────────────── /** Fresh batch reference for each test scenario to avoid idempotency collisions. */ From 04848e3d6e66f9c41917115ae2cab6e4f7c43d35 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 28 Jul 2026 12:08:04 +0200 Subject: [PATCH 5/7] UK Open Banking PSD2 --- release_notes.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/release_notes.md b/release_notes.md index 1c258e321c..3005375582 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,30 @@ ### Most recent changes at top of file ``` Date Commit Action +28/07/2026 bc2b2aeb3 BEHAVIOUR CHANGE: UK Open Banking PSD2 gate now reads the mTLS + transport certificate (PSD2-CERT), not TPP-Signature-Certificate. + When requirePsd2Certificates=ONLINE, the certificate identifying the + TPP is now taken per API standard (APIUtil.tppCertificateForStandard): + - Berlin Group URLs (/berlin-group/): TPP-Signature-Certificate + (the QSEAL signing certificate) — unchanged. + - UK Open Banking (/open-banking/) and OBP-native (/obp/) URLs: + PSD2-CERT (the mTLS transport certificate / QWAC, set by the + reverse proxy that terminates mTLS, or by in-process mTLS + termination via mtls.enabled=true). + Previously ALL standards read TPP-Signature-Certificate, which the + UK Open Banking specification never mentions. + + Who is affected: instances running requirePsd2Certificates=ONLINE + whose UK Open Banking TPPs currently pass the gate by sending + TPP-Signature-Certificate. After upgrading, those requests fail with + OBP-20306 (X509CannotGetCertificate) until the TPP's transport + certificate reaches OBP as PSD2-CERT. Before upgrading such an + instance, confirm the mTLS/proxy hop forwards the client certificate + (see docs/MTLS.md, "Which certificate identifies the TPP") and that + the regulated-entity records match the QWAC's issuer CN + serial + number (a QWAC and a QSEAL usually differ in both). Instances with + requirePsd2Certificates=NONE or CERTIFICATE are unaffected, as are + Berlin Group consumers. 05/06/2026 TBD FEATURE: Dynamic Entity field-level write/read role permissions (per-property writeRole/readRole + PATCH write path; read-restricted fields omitted from GET). See Glossary "Dynamic-Entities". From 0a8f623b6763ede74910d88769104bff0c9126d6 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 28 Jul 2026 12:45:08 +0200 Subject: [PATCH 6/7] Putting certificateTrust in metrics --- .../SwaggerDefinitionsJSON.scala | 4 +- .../main/scala/code/api/util/APIUtil.scala | 27 ++++-- .../main/scala/code/api/util/ApiSession.scala | 19 ++-- .../main/scala/code/api/util/OBPParam.scala | 2 + .../main/scala/code/api/util/PeerTrust.scala | 20 ++++- .../scala/code/api/util/WriteMetricUtil.scala | 14 ++- .../api/util/http4s/CallerCertificate.scala | 2 +- .../code/api/util/http4s/Http4sSupport.scala | 19 ++-- .../code/api/util/migration/Migration.scala | 13 +++ .../MigrationOfMetricCertificateTrust.scala | 86 +++++++++++++++++++ .../scala/code/api/v6_0_0/Http4s600.scala | 2 + .../code/api/v6_0_0/JSONFactory6.0.0.scala | 11 ++- .../main/scala/code/metrics/APIMetrics.scala | 10 ++- .../code/metrics/ElasticsearchMetrics.scala | 7 +- .../scala/code/metrics/MappedMetrics.scala | 41 ++++++++- .../code/metrics/MetricBatchWriter.scala | 15 ++-- .../scheduler/MetricsArchiveScheduler.scala | 4 +- .../bootstrap/http4s/NginxForwarderTest.scala | 2 +- .../scala/code/api/util/PeerTrustTest.scala | 16 ++++ .../util/http4s/CallerCertificateTest.scala | 12 ++- .../test/scala/code/metrics/MetricsTest.scala | 18 ++-- 21 files changed, 287 insertions(+), 57 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 553258f238..696f2af7a5 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -3195,7 +3195,9 @@ object SwaggerDefinitionsJSON { status_code = 401, operation_id = "OBPv4.0.0-getBanks", api_instance_id = "obp_node_a", - consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value) + consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value), + certificate_trust = Some("forwarded"), + certificate_trust_detail = Some("cn=nginx-prod-1,ou=edge,o=tesobe gmbh,c=de") ) lazy val metricsJsonV600 = MetricsJsonV600( metrics = List(metricJsonV600) 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 a90ce4a72a..272cba141d 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -162,14 +162,25 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ private val DateWithMsRollbackFormatTL = ThreadLocal.withInitial(() => new SimpleDateFormat(DateWithMsAndTimeZoneOffset)) private val rfc7231DateTL = ThreadLocal.withInitial(() => new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH)) - def DateWithYearFormat: SimpleDateFormat = DateWithYearFormatTL.get() - def DateWithMonthFormat: SimpleDateFormat = DateWithMonthFormatTL.get() - def DateWithDayFormat: SimpleDateFormat = DateWithDayFormatTL.get() - def DateWithSecondsFormat: SimpleDateFormat = DateWithSecondsFormatTL.get() + // SimpleDateFormat captures the JVM default time zone at CONSTRUCTION, and these instances are + // cached per thread. Boot.scala sets the default zone to UTC during startup, so an instance + // constructed on a thread that ran earlier (a test class's field initializers, an early boot + // thread on a non-UTC machine) would keep the machine's zone forever — while Calendar.getInstance + // elsewhere follows the new default, skewing parsed dates by the zone offset. Re-pin on every + // access so the cached instance always follows the current default; setTimeZone is a field write. + private def withCurrentZone(format: SimpleDateFormat): SimpleDateFormat = { + format.setTimeZone(java.util.TimeZone.getDefault) + format + } + + def DateWithYearFormat: SimpleDateFormat = withCurrentZone(DateWithYearFormatTL.get()) + def DateWithMonthFormat: SimpleDateFormat = withCurrentZone(DateWithMonthFormatTL.get()) + def DateWithDayFormat: SimpleDateFormat = withCurrentZone(DateWithDayFormatTL.get()) + def DateWithSecondsFormat: SimpleDateFormat = withCurrentZone(DateWithSecondsFormatTL.get()) // If you need UTC Z format, please continue to use DateWithMsFormat. eg: 2025-01-01T01:01:01.000Z - def DateWithMsFormat: SimpleDateFormat = DateWithMsFormatTL.get() + def DateWithMsFormat: SimpleDateFormat = withCurrentZone(DateWithMsFormatTL.get()) // If you need a format with timezone offset (+0000), please use DateWithMsRollbackFormat, eg: 2025-01-01T01:01:01.000+0000 - def DateWithMsRollbackFormat: SimpleDateFormat = DateWithMsRollbackFormatTL.get() + def DateWithMsRollbackFormat: SimpleDateFormat = withCurrentZone(DateWithMsRollbackFormatTL.get()) def rfc7231Date: SimpleDateFormat = rfc7231DateTL.get() @@ -1199,6 +1210,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case "iss" => Full(OBPIss(values.head)) case "consent_id" => Full(OBPConsentId(values.head)) case "consent_reference_id" => Full(OBPConsentReferenceId(values.head)) + case "certificate_trust" => Full(OBPCertificateTrust(values.head)) case "user_id" => Full(OBPUserId(values.head)) case "provider_provider_id" => Full(ProviderProviderId(values.head)) case "bank_id" => Full(OBPBankId(values.head)) @@ -1334,6 +1346,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val azp = getHttpRequestUrlParam(httpRequestUrl,"azp") val consentId = getHttpRequestUrlParam(httpRequestUrl,"consent_id") val consentReferenceId = getHttpRequestUrlParam(httpRequestUrl,"consent_reference_id") + val certificateTrust = getHttpRequestUrlParam(httpRequestUrl,"certificate_trust") val userId = getHttpRequestUrlParam(httpRequestUrl, "user_id") val providerProviderId = getHttpRequestUrlParam(httpRequestUrl, "provider_provider_id") val bankId = getHttpRequestUrlParam(httpRequestUrl, "bank_id") @@ -1369,7 +1382,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ Full(List( HTTPParam("sort_by",sortBy), HTTPParam("sort_direction",sortDirection), HTTPParam("from_date",fromDate), HTTPParam("to_date", toDate), HTTPParam("limit",limit), HTTPParam("offset",offset), - HTTPParam("anon", anon), HTTPParam("status", status), HTTPParam("consumer_id", consumerId), HTTPParam("azp", azp), HTTPParam("iss", iss), HTTPParam("consent_id", consentId), HTTPParam("consent_reference_id", consentReferenceId), HTTPParam("user_id", userId), HTTPParam("provider_provider_id", providerProviderId), HTTPParam("url", url), HTTPParam("app_name", appName), + HTTPParam("anon", anon), HTTPParam("status", status), HTTPParam("consumer_id", consumerId), HTTPParam("azp", azp), HTTPParam("iss", iss), HTTPParam("consent_id", consentId), HTTPParam("consent_reference_id", consentReferenceId), HTTPParam("certificate_trust", certificateTrust), HTTPParam("user_id", userId), HTTPParam("provider_provider_id", providerProviderId), HTTPParam("url", url), HTTPParam("app_name", appName), HTTPParam("implemented_by_partial_function",implementedByPartialFunction), HTTPParam("implemented_in_version",implementedInVersion), HTTPParam("verb", verb), HTTPParam("correlation_id", correlationId), HTTPParam("duration", duration), HTTPParam("exclude_app_names", excludeAppNames), HTTPParam("exclude_url_patterns", excludeUrlPattern),HTTPParam("exclude_implemented_by_partial_functions", excludeImplementedByPartialfunctions), diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 9259ad498b..8bff206d57 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -61,10 +61,13 @@ case class CallContext( counterparty: Option[CounterpartyTrait] = None, // Set when the request is authenticated via a consent. Persisted on metric rows for search/audit. consentReferenceId: Option[String] = None, - // How the caller's certificate was established: "direct", "forwarded via ", - // or "none: ". See PeerTrust.Resolution — the trust decision that turned a - // TLS peer plus a header into an identity. Not yet persisted on metric rows. - certificateTrust: Option[String] = None + // How the caller's certificate was established (PeerTrust.Resolution.mode): + // "direct", "forwarded" or "none". Persisted on metric rows as certificate_trust. + certificateTrust: Option[String] = None, + // The specifics behind certificateTrust (PeerTrust.Resolution.detail): the forwarding + // proxy's canonical subject DN, or the reason no caller was identified; None for + // "direct". Persisted on metric rows as certificate_trust_detail. + certificateTrustDetail: Option[String] = None ) extends MdcLoggable { override def toString: String = SecureLogging.maskSensitive( s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})" @@ -151,7 +154,9 @@ case class CallContext( xRateLimitReset = this.xRateLimitReset, paginationOffset = this.paginationOffset, paginationLimit = this.paginationLimit, - consentReferenceId = this.consentReferenceId + consentReferenceId = this.consentReferenceId, + certificateTrust = this.certificateTrust, + certificateTrustDetail = this.certificateTrustDetail ) } @@ -249,7 +254,9 @@ case class CallContextLight(gatewayLoginRequestPayload: Option[PayloadOfJwtJSON] xRateLimitReset : Long = -1, paginationOffset : Option[String] = None, paginationLimit : Option[String] = None, - consentReferenceId: Option[String] = None + consentReferenceId: Option[String] = None, + certificateTrust: Option[String] = None, + certificateTrustDetail: Option[String] = None ) trait LoginParam diff --git a/obp-api/src/main/scala/code/api/util/OBPParam.scala b/obp-api/src/main/scala/code/api/util/OBPParam.scala index dbd370eca3..6afeabfd7f 100644 --- a/obp-api/src/main/scala/code/api/util/OBPParam.scala +++ b/obp-api/src/main/scala/code/api/util/OBPParam.scala @@ -30,6 +30,8 @@ case class OBPAzp(value: String) extends OBPQueryParam case class OBPIss(value: String) extends OBPQueryParam case class OBPConsentId(value: String) extends OBPQueryParam case class OBPConsentReferenceId(value: String) extends OBPQueryParam +// PeerTrust.Resolution.mode on the metric row: "direct", "forwarded" or "none". +case class OBPCertificateTrust(value: String) extends OBPQueryParam case class OBPUserId(value: String) extends OBPQueryParam case class ProviderProviderId(value: String) extends OBPQueryParam case class OBPStatus(value: String) extends OBPQueryParam diff --git a/obp-api/src/main/scala/code/api/util/PeerTrust.scala b/obp-api/src/main/scala/code/api/util/PeerTrust.scala index 4e7904ef84..d8d27ea434 100644 --- a/obp-api/src/main/scala/code/api/util/PeerTrust.scala +++ b/obp-api/src/main/scala/code/api/util/PeerTrust.scala @@ -55,25 +55,43 @@ object PeerTrust extends MdcLoggable { sealed trait Resolution { /** The certificate identifying the caller, as it should reach the rest of OBP. */ def callerPem: Option[String] - /** One line for the log and the metric row. */ + /** + * The trust mode alone — "direct", "forwarded" or "none". This is what the metric row stores + * (certificate_trust): three values, so per-environment questions like "is everything here + * resolving as forwarded yet?" are a GROUP BY, not a LIKE over prose. + */ + def mode: String + /** + * The specifics behind the mode — the forwarding proxy's canonical subject DN for "forwarded", + * the rejection reason for "none", nothing for "direct" (the handshake says it all). Stored as + * certificate_trust_detail on the metric row. + */ + def detail: Option[String] + /** One line for the log: the mode and the detail together. */ def describe: String } /** The TLS peer is the caller: OBP is the edge. */ case class DirectCaller(pem: String) extends Resolution { val callerPem: Option[String] = Some(pem) + val mode: String = "direct" + val detail: Option[String] = None val describe: String = "direct" } /** A trusted forwarder named the caller. */ case class ForwardedCaller(pem: String, via: String) extends Resolution { val callerPem: Option[String] = Some(pem) + val mode: String = "forwarded" + val detail: Option[String] = Some(via) val describe: String = s"forwarded via $via" } /** Nobody is identified by a certificate. Most OBP endpoints do not need one. */ case class NoCaller(reason: String) extends Resolution { val callerPem: Option[String] = None + val mode: String = "none" + val detail: Option[String] = Some(reason) val describe: String = s"none: $reason" } diff --git a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala index 9f3af27d5e..8e7768e92a 100644 --- a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala +++ b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala @@ -70,7 +70,7 @@ object WriteMetricUtil extends MdcLoggable { publishMetricEvent(userId, cc.url, cc.startTime.getOrElse(null), duration, userName, appName, developerEmail, consumerId, implementedByPartialFunction, cc.implementedInVersion, cc.verb, cc.httpCode, cc.correlationId, sourceIp, targetIp, cc.operationId.getOrElse(""), - cc.consentReferenceId.orNull) + cc.consentReferenceId.orNull, cc.certificateTrust.orNull, cc.certificateTrustDetail.orNull) } } @@ -114,7 +114,9 @@ object WriteMetricUtil extends MdcLoggable { sourceIp, targetIp, code.api.Constant.ApiInstanceId, - cc.consentReferenceId.orNull + cc.consentReferenceId.orNull, + cc.certificateTrust.orNull, + cc.certificateTrustDetail.orNull ) } catch { case NonFatal(e) => @@ -146,7 +148,9 @@ object WriteMetricUtil extends MdcLoggable { sourceIp: String, targetIp: String, operationId: String, - consentReferenceId: String): Unit = { + consentReferenceId: String, + certificateTrust: String, + certificateTrustDetail: String): Unit = { if (!MetricsEventBus.isEnabled) return try { implicit val fmts = metricFormats @@ -171,7 +175,9 @@ object WriteMetricUtil extends MdcLoggable { "target_ip" -> Option(targetIp).getOrElse(""), "api_instance_id" -> code.api.Constant.ApiInstanceId, "operation_id" -> Option(operationId).getOrElse(""), - "consent_reference_id" -> Option(consentReferenceId).getOrElse("") + "consent_reference_id" -> Option(consentReferenceId).getOrElse(""), + "certificate_trust" -> Option(certificateTrust).getOrElse(""), + "certificate_trust_detail" -> Option(certificateTrustDetail).getOrElse("") )) MetricsEventBus.publish(payload) } catch { diff --git a/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala b/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala index b7cf8e1dd6..3b4387f452 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala @@ -55,7 +55,7 @@ object CallerCertificate extends MdcLoggable { case Some(pem) => req.putHeaders(Header.Raw(psd2CertHeaderName, pem)) case None => req.removeHeader(psd2CertHeaderName) } - withHeader.withAttribute(Http4sRequestAttributes.callerCertificateTrustKey, resolution.describe) + withHeader.withAttribute(Http4sRequestAttributes.callerCertificateTrustKey, resolution) } } } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index d8bce10c44..5aafa0b21a 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -68,16 +68,16 @@ object Http4sRequestAttributes { Key.newKey[IO, Option[String]].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) /** - * Vault key for how the caller's certificate was established — "direct", "forwarded via ", - * or "none: " (see PeerTrust.Resolution.describe). + * Vault key for how the caller's certificate was established — the full PeerTrust.Resolution, + * from which the CallContext takes certificateTrust (the mode: "direct" / "forwarded" / "none") + * and certificateTrustDetail (the forwarding proxy's subject DN, or the rejection reason). * - * Carried onto the CallContext (CallContext.certificateTrust) so metrics and audit CAN record it. - * NOTE: nothing persists it to metric rows yet — until that lands, the per-request trail is the - * CallerCertificate debug log. Without at least that, "why is this TPP suddenly anonymous" is - * answerable only by guessing at the deployment's trust configuration. + * Both are persisted on the metric row (certificate_trust / certificate_trust_detail), so "why + * is this TPP suddenly anonymous" is a metrics query rather than a guess at the deployment's + * trust configuration. */ - val callerCertificateTrustKey: Key[String] = - Key.newKey[IO, String].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + val callerCertificateTrustKey: Key[code.api.util.PeerTrust.Resolution] = + Key.newKey[IO, code.api.util.PeerTrust.Resolution].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) /** @@ -563,7 +563,8 @@ object Http4sCallContextBuilder { correlationId = extractCorrelationId(request), ipAddress = extractIpAddress(request), requestHeaders = extractHeaders(request), - certificateTrust = request.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey), + certificateTrust = request.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey).map(_.mode), + certificateTrustDetail = request.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey).flatMap(_.detail), httpBody = body, authReqHeaderField = extractAuthHeader(request), directLoginParams = extractDirectLoginParams(request), diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index 95853d132f..ffdf40165a 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -154,6 +154,7 @@ object Migration extends MdcLoggable { migrateChatRoomCreatedByAndLastMessageSender() migrateConsentReferenceIdToUuid(startedBeforeSchemifier) migrateMetricConsentReferenceId(startedBeforeSchemifier) + migrateMetricCertificateTrust(startedBeforeSchemifier) dropFastFirehoseAccountsViews(startedBeforeSchemifier) } @@ -775,6 +776,18 @@ object Migration extends MdcLoggable { } } + private def migrateMetricCertificateTrust(startedBeforeSchemifier: Boolean): Boolean = { + if(startedBeforeSchemifier == true) { + logger.warn(s"Migration.database.migrateMetricCertificateTrust(true) cannot be run before Schemifier.") + true + } else { + val name = nameOf(migrateMetricCertificateTrust(startedBeforeSchemifier)) + runOnce(name) { + MigrationOfMetricCertificateTrust.migrate(name) + } + } + } + private def addAccountAccessWithViewsView(startedBeforeSchemifier: Boolean): Boolean = { if(startedBeforeSchemifier == true) { logger.warn(s"Migration.database.addAccountAccessWithViewsView(true) cannot be run before Schemifier.") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala new file mode 100644 index 0000000000..d479b6085a --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala @@ -0,0 +1,86 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.metrics.MappedMetric +import net.liftweb.mapper.Schemifier + +/** + * Migration: add `certificate_trust VARCHAR(32)` and `certificate_trust_detail VARCHAR(255)` to + * both the live `metric` table and the `metricarchive` table. + * + * The columns persist PeerTrust.Resolution per request — how the caller's certificate was + * established (`direct` / `forwarded` / `none`) and the specifics (the forwarding proxy's subject + * DN, or the rejection reason). See docs/MTLS_TOPOLOGIES.md §5.5: this is the audit trail the + * prod-behind-nginx rollout gates on. + * + * No backfill: historical rows predate the trust decision; nullable columns. + * + * No index: certificate_trust holds three distinct values, so an index on it alone is useless — + * queries combine it with the already-indexed date range. + * + * Unlike MigrationOfMetricConsentReferenceId this takes NO backup of the metric table first: the + * change is purely additive (nullable columns, no rewrite of existing data), and the metric table + * is routinely the largest table in a deployment — copying it to add two nullable columns costs + * more than the operation it would insure. + * + * Lift's Schemifier auto-creates the columns on fresh deploys from the updated model; this + * migration handles existing deploys. + */ +object MigrationOfMetricCertificateTrust { + + def migrate(name: String): Boolean = { + DbFunction.tableExists(MappedMetric) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val dbDriver = APIUtil.getPropsValue("db.driver") openOr "org.h2.Driver" + val isMssql = dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") + var isSuccessful = false + val sqlLog = new StringBuilder() + + try { + // Both tables are referenced as lowercase unquoted names, matching every other SQL site + // in the codebase (Schemifier emits unquoted DDL, so Postgres folds to lowercase). + val statements = + if (isMssql) List( + "ALTER TABLE metric ADD certificate_trust VARCHAR(32) NULL;", + "ALTER TABLE metric ADD certificate_trust_detail VARCHAR(255) NULL;", + "ALTER TABLE metricarchive ADD certificate_trust VARCHAR(32) NULL;", + "ALTER TABLE metricarchive ADD certificate_trust_detail VARCHAR(255) NULL;" + ) else List( + "ALTER TABLE metric ADD COLUMN IF NOT EXISTS certificate_trust VARCHAR(32);", + "ALTER TABLE metric ADD COLUMN IF NOT EXISTS certificate_trust_detail VARCHAR(255);", + "ALTER TABLE metricarchive ADD COLUMN IF NOT EXISTS certificate_trust VARCHAR(32);", + "ALTER TABLE metricarchive ADD COLUMN IF NOT EXISTS certificate_trust_detail VARCHAR(255);" + ) + statements.foreach { statement => + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => statement)).append("\n") + } + + isSuccessful = true + } catch { + case e: Exception => + isSuccessful = false + sqlLog.append(s"\nException: ${e.getMessage}\n") + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$sqlLog + |""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = s"""${MappedMetric._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 90802d95c5..77a7e7d315 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -7293,6 +7293,8 @@ object Http4s600 { | |17 consent_reference_id (if null ignore) - Returns calls authenticated via the consent with this reference id. eg: consent_reference_id=fd13b9af-4f74-4d52-a7f1-7c2c12f3aa11 | + |18 certificate_trust (if null ignore) - Returns calls by how the caller's certificate was established: direct (the TLS peer was the caller), forwarded (a trusted proxy forwarded the caller's certificate) or none (certificate material was present but no caller was identified). eg: certificate_trust=forwarded + | """.stripMargin, EmptyBody, metricsJsonV600, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index c704b4fff6..279291d089 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -451,7 +451,12 @@ case class MetricJsonV600( status_code: Int, operation_id: String, api_instance_id: String, - consent_reference_id: Option[String] + consent_reference_id: Option[String], + // How the caller's certificate was established: "direct", "forwarded" or "none"; + // absent when the request carried no certificate material. See PeerTrust.Resolution. + certificate_trust: Option[String], + // The forwarding proxy's subject DN, or the reason no caller was identified. + certificate_trust_detail: Option[String] ) case class MetricsJsonV600(metrics: List[MetricJsonV600]) @@ -1695,7 +1700,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { status_code = metric.getHttpCode(), operation_id = operationId, api_instance_id = metric.getApiInstanceId(), - consent_reference_id = Option(metric.getConsentReferenceId()).filter(_.nonEmpty) + consent_reference_id = Option(metric.getConsentReferenceId()).filter(_.nonEmpty), + certificate_trust = Option(metric.getCertificateTrust()).filter(_.nonEmpty), + certificate_trust_detail = Option(metric.getCertificateTrustDetail()).filter(_.nonEmpty) ) } diff --git a/obp-api/src/main/scala/code/metrics/APIMetrics.scala b/obp-api/src/main/scala/code/metrics/APIMetrics.scala index f252af2892..ece284cf7e 100644 --- a/obp-api/src/main/scala/code/metrics/APIMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/APIMetrics.scala @@ -56,7 +56,9 @@ trait APIMetrics { sourceIp: String, targetIp: String, apiInstanceId: String, - consentReferenceId: String): Unit + consentReferenceId: String, + certificateTrust: String, + certificateTrustDetail: String): Unit def saveMetricsArchive(primaryKey: Long, userId: String, @@ -76,7 +78,9 @@ trait APIMetrics { sourceIp: String, targetIp: String, apiInstanceId: String, - consentReferenceId: String + consentReferenceId: String, + certificateTrust: String, + certificateTrustDetail: String ): Boolean // //TODO: ordering of list? should this be by date? currently not enforced @@ -127,6 +131,8 @@ trait APIMetric { def getTargetIp(): String def getApiInstanceId(): String def getConsentReferenceId(): String + def getCertificateTrust(): String + def getCertificateTrustDetail(): String } diff --git a/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala b/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala index db8e71c184..a6a5aaefef 100644 --- a/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/ElasticsearchMetrics.scala @@ -14,7 +14,8 @@ object ElasticsearchMetrics extends APIMetrics { lazy val es = new elasticsearchMetrics override def saveMetric(userId: String, url: String, date: Date, duration: Long, userName: String, appName: String, developerEmail: String, consumerId: String, implementedByPartialFunction: String, implementedInVersion: String, verb: String, httpCode: Option[Int], correlationId: String, - responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String): Unit = { + responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String, + certificateTrust: String, certificateTrustDetail: String): Unit = { if (APIUtil.getPropsAsBoolValue("allow_elasticsearch", false) && APIUtil.getPropsAsBoolValue("allow_elasticsearch_metrics", false) ) { //TODO ,need to be fixed now add more parameters es.indexMetric(userId, url, date, duration, userName, appName, developerEmail, correlationId, apiInstanceId) @@ -25,7 +26,9 @@ object ElasticsearchMetrics extends APIMetrics { sourceIp: String, targetIp: String, apiInstanceId: String, - consentReferenceId: String): Boolean = ??? + consentReferenceId: String, + certificateTrust: String, + certificateTrustDetail: String): Boolean = ??? // override def getAllGroupedByUserId(): Map[String, List[APIMetric]] = { // //TODO: replace the following with valid ES query diff --git a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala index 24280ce63a..417659ad68 100644 --- a/obp-api/src/main/scala/code/metrics/MappedMetrics.scala +++ b/obp-api/src/main/scala/code/metrics/MappedMetrics.scala @@ -113,7 +113,8 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ } override def saveMetric(userId: String, url: String, date: Date, duration: Long, userName: String, appName: String, developerEmail: String, consumerId: String, implementedByPartialFunction: String, implementedInVersion: String, verb: String, httpCode: Option[Int], correlationId: String, - responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String): Unit = { + responseBody: String, sourceIp: String, targetIp: String, apiInstanceId: String, consentReferenceId: String, + certificateTrust: String, certificateTrustDetail: String): Unit = { // A correlation id is expected on every metric. Rows without one cannot be moved // to the archive later (its correlationId column requires a UUID), so flag it at // write time where the source of the missing id can actually be traced. @@ -139,7 +140,9 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ sourceIp = sourceIp, targetIp = targetIp, apiInstanceId = apiInstanceId, - consentReferenceId = consentReferenceId + consentReferenceId = consentReferenceId, + certificateTrust = certificateTrust, + certificateTrustDetail = certificateTrustDetail ) ) } @@ -149,7 +152,8 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ implementedByPartialFunction: String, implementedInVersion: String, verb: String, httpCode: Option[Int], correlationId: String, responseBody: String, sourceIp: String, targetIp: String, - apiInstanceId: String, consentReferenceId: String): Boolean = { + apiInstanceId: String, consentReferenceId: String, + certificateTrust: String, certificateTrustDetail: String): Boolean = { // Fix: dedup by the source metric's primary key stored in `metricId`, NOT by the // archive's own auto-increment `id`. The two are unrelated id-spaces; matching on // `id` overwrites an unrelated archived row once the archive's id sequence grows @@ -175,6 +179,8 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ .targetIp(targetIp) .apiInstanceId(apiInstanceId) .consentReferenceId(consentReferenceId) + .certificateTrust(certificateTrust) + .certificateTrustDetail(certificateTrustDetail) httpCode match { case Some(code) => metric.httpCode(code) @@ -282,6 +288,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ val duration = queryParams.collect { case OBPDuration(value) => By_>(MappedMetric.duration, value) }.headOption val httpStatusCode = queryParams.collect { case OBPHttpStatusCode(value) => By(MappedMetric.httpCode, value) }.headOption val consentReferenceId = queryParams.collect { case OBPConsentReferenceId(value) => By(MappedMetric.consentReferenceId, value) }.headOption + val certificateTrust = queryParams.collect { case OBPCertificateTrust(value) => By(MappedMetric.certificateTrust, value) }.headOption val anon = queryParams.collect { case OBPAnon(true) => By(MappedMetric.userId, "null") case OBPAnon(false) => NotBy(MappedMetric.userId, "null") @@ -309,6 +316,7 @@ object MappedMetrics extends APIMetrics with MdcLoggable{ duration.toSeq, httpStatusCode.toSeq, consentReferenceId.toSeq, + certificateTrust.toSeq, anon.toSeq, excludeAppNames.toSeq.flatten ).flatten @@ -680,6 +688,19 @@ class MappedMetric extends APIMetric with LongKeyedMapper[MappedMetric] with IdP override def dbColumnName = "consent_reference_id" override def defaultValue = null } + // How the caller's certificate was established (PeerTrust.Resolution.mode): "direct", + // "forwarded" or "none". Null when the request carried no certificate material at all. + // Not indexed: three values combined with the indexed date range is selective enough. + object certificateTrust extends MappedString(this, 32) { + override def dbColumnName = "certificate_trust" + override def defaultValue = null + } + // The specifics behind certificateTrust (PeerTrust.Resolution.detail): the forwarding proxy's + // canonical subject DN for "forwarded", the rejection reason for "none". Null for "direct". + object certificateTrustDetail extends MappedString(this, 255) { + override def dbColumnName = "certificate_trust_detail" + override def defaultValue = null + } override def getMetricId(): Long = id.get override def getUrl(): String = url.get @@ -700,6 +721,8 @@ class MappedMetric extends APIMetric with LongKeyedMapper[MappedMetric] with IdP override def getTargetIp(): String = targetIp.get override def getApiInstanceId(): String = apiInstanceId.get override def getConsentReferenceId(): String = consentReferenceId.get + override def getCertificateTrust(): String = certificateTrust.get + override def getCertificateTrustDetail(): String = certificateTrustDetail.get } object MappedMetric extends MappedMetric with LongKeyedMetaMapper[MappedMetric] { @@ -755,6 +778,16 @@ class MetricArchive extends APIMetric with LongKeyedMapper[MetricArchive] with I override def dbColumnName = "consent_reference_id" override def defaultValue = null } + // Mirror of Metric.certificateTrust / certificateTrustDetail — same widths, or the archiver + // fails on copy (see the correlationId width lesson above). + object certificateTrust extends MappedString(this, 32) { + override def dbColumnName = "certificate_trust" + override def defaultValue = null + } + object certificateTrustDetail extends MappedString(this, 255) { + override def dbColumnName = "certificate_trust_detail" + override def defaultValue = null + } override def getMetricId(): Long = metricId.get @@ -776,6 +809,8 @@ class MetricArchive extends APIMetric with LongKeyedMapper[MetricArchive] with I override def getTargetIp(): String = targetIp.get override def getApiInstanceId(): String = apiInstanceId.get override def getConsentReferenceId(): String = consentReferenceId.get + override def getCertificateTrust(): String = certificateTrust.get + override def getCertificateTrustDetail(): String = certificateTrustDetail.get } object MetricArchive extends MetricArchive with LongKeyedMetaMapper[MetricArchive] { override def dbIndexes = diff --git a/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala b/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala index 392673ad3c..d1a05d5ba2 100644 --- a/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala +++ b/obp-api/src/main/scala/code/metrics/MetricBatchWriter.scala @@ -41,7 +41,9 @@ object MetricBatchWriter extends MdcLoggable { sourceIp: String, targetIp: String, apiInstanceId: String, - consentReferenceId: String + consentReferenceId: String, + certificateTrust: String, + certificateTrustDetail: String ) private val queue = new ConcurrentLinkedQueue[MetricRow]() @@ -103,8 +105,9 @@ object MetricBatchWriter extends MdcLoggable { userid, url, date_c, duration, username, appname, developeremail, consumerid, implementedbypartialfunction, implementedinversion, verb, httpcode, correlationid, - responsebody, sourceip, targetip, apiinstanceid, consent_reference_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + responsebody, sourceip, targetip, apiinstanceid, consent_reference_id, + certificate_trust, certificate_trust_detail + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ // Use Option[String] so Doobie handles nullable fields via Put[Option[String]] @@ -113,7 +116,8 @@ object MetricBatchWriter extends MdcLoggable { (Option[String], Option[String], Timestamp, Long, Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], Int, Option[String], - Option[String], Option[String], Option[String], Option[String], Option[String]) + Option[String], Option[String], Option[String], Option[String], Option[String], + Option[String], Option[String]) ](insertSql) val values = rows.map { r => @@ -123,7 +127,8 @@ object MetricBatchWriter extends MdcLoggable { Option(r.developerEmail), Option(r.consumerId), Option(r.implementedByPartialFunction), Option(r.implementedInVersion), Option(r.verb), r.httpCode, Option(r.correlationId), Option(r.responseBody), Option(r.sourceIp), Option(r.targetIp), Option(r.apiInstanceId), - Option(r.consentReferenceId) + Option(r.consentReferenceId), + Option(r.certificateTrust), Option(r.certificateTrustDetail) ) } diff --git a/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala b/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala index 0cd86f8a4b..4633114f1b 100644 --- a/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala @@ -225,7 +225,9 @@ object MetricsArchiveScheduler extends MdcLoggable { i.getSourceIp(), i.getTargetIp(), i.getApiInstanceId(), - i.getConsentReferenceId() + i.getConsentReferenceId(), + i.getCertificateTrust(), + i.getCertificateTrustDetail() ) } diff --git a/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala b/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala index 8e053aedf7..ba942e4183 100644 --- a/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala +++ b/obp-api/src/test/scala/bootstrap/http4s/NginxForwarderTest.scala @@ -81,7 +81,7 @@ class NginxForwarderTest extends FlatSpec with Matchers with BeforeAndAfterAll { val config = if (req.uri.path.renderString.startsWith("/untrusted")) emptyAllowlist else trustsNginx val resolved = CallerCertificate.resolveCaller(Psd2CertIngress.canonicalize(req), config) val caller = resolved.headers.get(CIString("PSD2-CERT")).map(_.head.value).getOrElse("NONE") - val trust = resolved.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey).getOrElse("none") + val trust = resolved.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey).map(_.describe).getOrElse("none") IO.pure(Response[IO](Status.Ok).withEntity(caller).putHeaders(Header.Raw(CIString("X-Trust"), trust))) } diff --git a/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala b/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala index 4e5b8ef347..64afedae45 100644 --- a/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala +++ b/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala @@ -118,6 +118,22 @@ class PeerTrustTest extends FlatSpec with Matchers { canonicalDn("") shouldBe None } + // The metric row stores the structured form (certificate_trust / certificate_trust_detail), + // not the describe sentence — so the mode/detail split is contract, not presentation. + "the resolution's structured form" should "expose mode and detail separately" in { + val forwarded = resolve(Some(proxyCert), Some(forwardedPem), proxyIsTrusted) + forwarded.mode shouldEqual "forwarded" + forwarded.detail shouldEqual Some(canonical(ProxyCn)) + + val direct = resolve(Some(appCert), None, proxyIsTrusted) + direct.mode shouldEqual "direct" + direct.detail shouldBe None + + val none = resolve(None, None, proxyIsTrusted) + none.mode shouldEqual "none" + none.detail.get should include("no TLS client certificate") + } + // mtls.trust_forwarded_header_without_tls exists for the plain-proxy-hop deployment. When OBP // terminates mTLS itself and no forwarder is configured it is provably the edge, and the prop's // permissive default would re-open the spoofing hole the pre-generalisation middleware closed diff --git a/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala b/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala index e951cb184b..bd5a391b0a 100644 --- a/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala +++ b/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala @@ -104,9 +104,15 @@ class CallerCertificateTest extends FlatSpec with Matchers { CallerCertificate.resolveCaller(req, config) .attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey) - trustOf(requestWith(Some(clientCert), None), asEdge) shouldEqual Some("direct") - trustOf(requestWith(Some(proxyCert), Some("pem")), behindProxy).get should startWith("forwarded via") - trustOf(requestWith(None, Some("pem")), asEdge).get should startWith("none:") + trustOf(requestWith(Some(clientCert), None), asEdge).map(_.mode) shouldEqual Some("direct") + trustOf(requestWith(Some(proxyCert), Some("pem")), behindProxy).get.mode shouldEqual "forwarded" + trustOf(requestWith(None, Some("pem")), asEdge).get.mode shouldEqual "none" + + // The detail is what makes the metric row diagnosable: WHICH proxy vouched, or WHY nobody did. + trustOf(requestWith(Some(clientCert), None), asEdge).get.detail shouldBe None + trustOf(requestWith(Some(proxyCert), Some("pem")), behindProxy).get.detail shouldEqual + Some(PeerTrust.canonicalDn("CN=nginx-prod-1").get) + trustOf(requestWith(None, Some("pem")), asEdge).get.detail.get should include("no client certificate") } it should "not be set on a request that carries no certificate material at all" in { diff --git a/obp-api/src/test/scala/code/metrics/MetricsTest.scala b/obp-api/src/test/scala/code/metrics/MetricsTest.scala index ff0c6d4f12..729abd8cdd 100644 --- a/obp-api/src/test/scala/code/metrics/MetricsTest.scala +++ b/obp-api/src/test/scala/code/metrics/MetricsTest.scala @@ -65,7 +65,7 @@ class MetricsTest extends ServerSetup with WipeMetrics { scenario("We save a new API metric") { metrics.saveMetric(testUserId,testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) MetricBatchWriter.flush() val byUrl = metrics.getAllMetrics(List(OBPLimit(limit))).groupBy(_.getUrl()) @@ -83,16 +83,16 @@ class MetricsTest extends ServerSetup with WipeMetrics { scenario("Group all metrics by url") { metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) metrics.saveMetric(testUserId, testUrl1, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) metrics.saveMetric(testUserId, testUrl2, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) MetricBatchWriter.flush() val byUrl = metrics.getAllMetrics(List(OBPLimit(limit1))).groupBy(_.getUrl()) @@ -113,16 +113,16 @@ class MetricsTest extends ServerSetup with WipeMetrics { scenario("Group all metrics by day") { metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) metrics.saveMetric(testUserId, testUrl1, day1, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) metrics.saveMetric(testUserId, testUrl1, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) metrics.saveMetric(testUserId, testUrl2, day2, -1L, testUserName, testAppName, testDeveloperEmail, testConsumerId, testImplementedByPartialFunction, - testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null) + testVersion, testVerb, None, getCorrelationId(), testResponseBody, testSourceIp , testTargetIp, testApiInstanceId, null, null, null) MetricBatchWriter.flush() val byDay = metrics.getAllMetrics(List(OBPLimit(limit2))).groupBy(APIMetrics.getMetricDay) From 4be70a4bb161ce33cad264343fcf53a8c85070c2 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 28 Jul 2026 15:52:47 +0200 Subject: [PATCH 7/7] Fixing OC test --- .../rabbitmq/RabbitMQConnector_vOct2024.scala | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala index 99014e85be..52cd5918ec 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala @@ -7349,12 +7349,17 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { // no outboundAdapterCallContext wrapper). The reply arrives on `replyTo` // correlated by `correlationId`, as the OBP inbound envelope with // `status.errorCode == ""` meaning success. + // + // `process` below follows the connector-wide doc convention (obp.camelCase); + // the on-wire AMQP messageId stays snake_case (`obp_credit_notification` / + // `obp_settlement_instruction`) exactly as the Bank Node expects. messageDocs += openCorridorCreditNotificationDoc def openCorridorCreditNotificationDoc = MessageDoc( - process = "obp_credit_notification", + process = "obp.openCorridorCreditNotification", messageFormat = messageFormat, description = "Open Corridor: notify the creditor (beneficiary) bank's Bank Node to credit its customer. " + + "AMQP messageId on the wire: obp_credit_notification. " + "Carries the commit–reveal evidence triplet (promise_commitment, promise_salt, promise_preimage) " + "relayed verbatim from the originating Bank Node's promise report-back, so the beneficiary can verify " + "SHA-256(salt ‖ preimage) against the on-chain commitment. Published to the creditor bank's vhost.", @@ -7392,10 +7397,10 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { messageDocs += openCorridorSettlementInstructionDoc def openCorridorSettlementInstructionDoc = MessageDoc( - process = "obp_settlement_instruction", + process = "obp.openCorridorSettlementInstruction", messageFormat = messageFormat, description = "Open Corridor: instruct the debtor bank's Bank Node to settle the net amount on its " + - "settlement rail (e.g. cardano-ada). `amount` is in major units. `idempotency_key` is required — " + + "settlement rail (e.g. cardano-ada). AMQP messageId on the wire: obp_settlement_instruction. `amount` is in major units. `idempotency_key` is required — " + "the Bank Node keeps a durable settlement record per key and never pays twice; re-publishing the same " + "instruction returns the recorded state (redelivery doubles as SUBMITTED → FINAL status polling). " + "Published to the debtor bank's vhost.",