From e3ecea85a7fb31203c7ebeb4669c4f03ece692eb Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 25 Aug 2026 04:51:55 +0200 Subject: [PATCH 1/5] Delete User documentation, MCP Architecture diagram; glossary, lift parity check script --- .../main/scala/code/api/util/Glossary.scala | 12 +++++++++ .../scala/code/api/v4_0_0/Http4s400.scala | 17 +++++++++++- .../check_lift_http4s_resource_doc_parity.py | 27 +++++++++++++++---- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index b3a0792f76..2c4c12c5e6 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5894,6 +5894,14 @@ object Glossary extends MdcLoggable { |└──────────────────┘ └────────────────────────┘ └──────────────┘ |``` | + |## Architecture diagram + | + |The full picture — Portal/API Explorer, Opey, external MCP clients (Claude Code, Claude Desktop, IDE agents), OBP-OIDC, the numbered consent flow, and OBP-API down to the core banking systems: + | + |![How Opey, Claude Code and OBP-MCP call OBP-API](https://github.com/user-attachments/assets/d3ff5c10-7167-4034-98f7-c53a323bf985) + | + |The editable master is a Lucidchart document linked from the [OBP-MCP README](https://github.com/OpenBankProject/OBP-MCP#architecture). + | |## Three-step discovery + call (no RAG, no vector DB) | |OBP-MCP avoids embedding the 4 MB OpenAPI spec into the LLM's context. Instead it exposes three tools that work together: @@ -5954,6 +5962,10 @@ object Glossary extends MdcLoggable { | |Since [OBP-MCP](/glossary#OBP-MCP) was introduced, Opey has been refactored from a self-contained chatbot (with its own endpoint search, glossary search, and OBP HTTP client baked in) into a focused **agent** that *consumes* OBP-MCP as its primary tool source. | + |![How Opey, Claude Code and OBP-MCP call OBP-API](https://github.com/user-attachments/assets/d3ff5c10-7167-4034-98f7-c53a323bf985) + | + |Besides the MCP path shown above, Opey makes some direct HTTP calls to OBP-API for its own infrastructure (session validation via `/users/current`, admin DirectLogin operations, persisting LangGraph checkpoints as dynamic entities, and health probes) — see the architecture section of the [Opey README](https://github.com/OpenBankProject/OBP-Opey-II#architecture-how-opey-reaches-the-obp-api) for the detail diagram. + | |Opey's `mcp_servers.json` typically points at a running OBP-MCP instance: | |```json diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 6851508e34..328fbd7a62 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -7599,6 +7599,9 @@ object Http4s400 { http4sPartialFunction = Some(deleteTransactionRequestAttributeDefinition) ) + // Intentional drift from the Lift baseline: description expanded to document the + // scramble (soft delete) behaviour, and UserNotFoundById added to the error list + // (the handler returns 404 via NewStyle.function.findByUserId). staticResourceDocs += ResourceDoc( implementedInApiVersion, nameOf(deleteUser), @@ -7607,13 +7610,25 @@ object Http4s400 { "Delete a User", s"""Delete a User. | + |This is a soft delete: the database row is kept, but the User's personal data is scrambled i.e. overwritten with random values: + | + |* The username is replaced with DELETED- + |* The first name, last name and email are replaced with random values + |* The password is replaced with a random value and the user is invalidated, so the User can no longer log in + |* Any User Invitation that created the User is scrambled in the same way + | + |The User is marked as deleted; any subsequent authentication as this User (including via existing tokens or consents) is rejected. + | + |The USER_ID is retained, so records that reference it (e.g. metrics and transaction history) keep their audit value but can no longer be linked to a person. + | + |This action cannot be undone. | |${userAuthenticationMessage(true)} | |""", EmptyBody, EmptyBody, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List($AuthenticatedUserIsRequired, UserNotFoundById, UserHasMissingRoles, UnknownError), List(apiTagUser), Some(List(canDeleteUser)), http4sPartialFunction = Some(deleteUser) diff --git a/scripts/check_lift_http4s_resource_doc_parity.py b/scripts/check_lift_http4s_resource_doc_parity.py index 352e5e421c..c400cca811 100755 --- a/scripts/check_lift_http4s_resource_doc_parity.py +++ b/scripts/check_lift_http4s_resource_doc_parity.py @@ -36,7 +36,9 @@ REPO_ROOT = Path(__file__).resolve().parents[1] API_ROOT = REPO_ROOT / "obp-api" / "src" / "main" / "scala" / "code" / "api" -# Positional fields in ResourceDoc(...) — see APIUtil.scala:1589. +# Positional fields in the pre-teardown ResourceDoc(...) signature, which is what +# the commented-out Lift baselines still use — the endpoint partial function was +# the first parameter. POSITIONAL_FIELDS = [ "partialFunction", # 0 "implementedInApiVersion", # 1 @@ -51,6 +53,15 @@ "tags", # 10 ] +# Current signature (the Lift teardown removed the leading partialFunction +# parameter — see CLAUDE.md Rule 1); used by all active Http4s*.scala files. +CURRENT_POSITIONAL_FIELDS = POSITIONAL_FIELDS[1:] + +# The verb literal is the discriminator between the two signatures: it sits at +# positional index 3 in the old signature and index 2 in the current one. +HTTP_VERB_LITERALS = {'"%s"' % v for v in + ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS")} + # Default fields included in the diff report. DEFAULT_DIFF_FIELDS = [ "requestVerb", @@ -268,11 +279,17 @@ def parse_resourcedoc(body: str): else: named[n] = v out = OrderedDict() - for fname, val in zip(POSITIONAL_FIELDS, positional): + # Detect which constructor signature this doc uses by where the verb + # literal sits: index 2 → current signature (no leading partialFunction), + # index 3 → old signature (Lift baseline comments). + fields = POSITIONAL_FIELDS + if len(positional) > 2 and positional[2].strip() in HTTP_VERB_LITERALS: + fields = CURRENT_POSITIONAL_FIELDS + for fname, val in zip(fields, positional): out[fname] = val - if len(positional) > len(POSITIONAL_FIELDS): - # 12th positional is roles (Option[List[ApiRole]]). - extra = positional[len(POSITIONAL_FIELDS):] + if len(positional) > len(fields): + # Next positional after tags is roles (Option[List[ApiRole]]). + extra = positional[len(fields):] if "roles" not in named and extra: named["roles"] = extra[0] out.update(named) From 0d20e5e2a772d81f457023569facc787f3e94302 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 25 Aug 2026 04:59:46 +0200 Subject: [PATCH 2/5] Adding brand-assets (pulled from https://www.openbankproject.com/brand-assets/) --- brand-assets/README.md | 31 +++++++ .../obp-logo-horizontal-colour-small.png | Bin 0 -> 3968 bytes .../horizontal/obp-logo-horizontal-colour.png | Bin 0 -> 18325 bytes .../obp-logo-horizontal-green-white.png | Bin 0 -> 16248 bytes .../horizontal/obp-logo-horizontal-white.png | Bin 0 -> 12609 bytes .../horizontal/obp-logo-horizontal.svg | 80 ++++++++++++++++++ brand-assets/icon/obp-logo-icon-colour.png | Bin 0 -> 2687 bytes brand-assets/icon/obp-logo-icon-dark.png | Bin 0 -> 1533 bytes brand-assets/icon/obp-logo-icon-white.png | Bin 0 -> 1759 bytes .../vertical/obp-logo-vertical-all-white.png | Bin 0 -> 11518 bytes .../obp-logo-vertical-colour-small.png | Bin 0 -> 6220 bytes .../vertical/obp-logo-vertical-dark-small.png | Bin 0 -> 3429 bytes .../obp-logo-vertical-white-black.png | Bin 0 -> 16484 bytes .../obp-logo-vertical-white-small.png | Bin 0 -> 3899 bytes .../vertical/obp-logo-vertical-white.png | Bin 0 -> 14186 bytes 15 files changed, 111 insertions(+) create mode 100644 brand-assets/README.md create mode 100644 brand-assets/horizontal/obp-logo-horizontal-colour-small.png create mode 100644 brand-assets/horizontal/obp-logo-horizontal-colour.png create mode 100644 brand-assets/horizontal/obp-logo-horizontal-green-white.png create mode 100644 brand-assets/horizontal/obp-logo-horizontal-white.png create mode 100644 brand-assets/horizontal/obp-logo-horizontal.svg create mode 100644 brand-assets/icon/obp-logo-icon-colour.png create mode 100644 brand-assets/icon/obp-logo-icon-dark.png create mode 100644 brand-assets/icon/obp-logo-icon-white.png create mode 100644 brand-assets/vertical/obp-logo-vertical-all-white.png create mode 100644 brand-assets/vertical/obp-logo-vertical-colour-small.png create mode 100644 brand-assets/vertical/obp-logo-vertical-dark-small.png create mode 100644 brand-assets/vertical/obp-logo-vertical-white-black.png create mode 100644 brand-assets/vertical/obp-logo-vertical-white-small.png create mode 100644 brand-assets/vertical/obp-logo-vertical-white.png diff --git a/brand-assets/README.md b/brand-assets/README.md new file mode 100644 index 0000000000..f28872a67b --- /dev/null +++ b/brand-assets/README.md @@ -0,0 +1,31 @@ +# Open Bank Project Brand Assets + +Official Open Bank Project logos, kept in this repository so that documentation, +diagrams and glossary items can reference them from a stable, version-controlled +location instead of hotlinking the website. + +Source: https://www.openbankproject.com/brand-assets/ (downloaded 2026-08-25). +These assets are designated for **press and official partner use only**. + +## Files + +| File | Size | Use | +|---|---|---| +| `horizontal/obp-logo-horizontal.svg` | vector | Preferred where SVG is supported | +| `horizontal/obp-logo-horizontal-colour.png` | 1000×127 | Full logo, light backgrounds | +| `horizontal/obp-logo-horizontal-white.png` | 1000×127 | Full logo, dark backgrounds | +| `horizontal/obp-logo-horizontal-green-white.png` | 1000×127 | Green/white variant | +| `horizontal/obp-logo-horizontal-colour-small.png` | 206×28 | Small inline use | +| `vertical/obp-logo-vertical-*.png` | 421×202 / 154×78 | Stacked logo variants | +| `icon/obp-logo-icon-colour.png` | 79×78 | Icon only, light backgrounds | +| `icon/obp-logo-icon-dark.png` | 79×78 | Icon only, dark variant | +| `icon/obp-logo-icon-white.png` | 79×78 | Icon only, dark backgrounds | + +## Hotlinking + +Once merged to the `develop` branch of `OpenBankProject/OBP-API`, files can be +referenced from markdown and diagrams via, e.g.: + +``` +https://raw.githubusercontent.com/OpenBankProject/OBP-API/develop/brand-assets/horizontal/obp-logo-horizontal-colour.png +``` diff --git a/brand-assets/horizontal/obp-logo-horizontal-colour-small.png b/brand-assets/horizontal/obp-logo-horizontal-colour-small.png new file mode 100644 index 0000000000000000000000000000000000000000..a0b75b39fc66819caa31b62d5c907240128f987c GIT binary patch literal 3968 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 Zr|kOr%u9QFJMa`822WQ%mvv4FO#q8ZBisN0 literal 0 HcmV?d00001 diff --git a/brand-assets/horizontal/obp-logo-horizontal-green-white.png b/brand-assets/horizontal/obp-logo-horizontal-green-white.png new file mode 100644 index 0000000000000000000000000000000000000000..4b0b9046329e2b902778bb1cf2892b1ebd97c6db GIT binary patch literal 16248 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
  • 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 literal 0 HcmV?d00001 diff --git a/brand-assets/horizontal/obp-logo-horizontal.svg b/brand-assets/horizontal/obp-logo-horizontal.svg new file mode 100644 index 0000000000..966461ff07 --- /dev/null +++ b/brand-assets/horizontal/obp-logo-horizontal.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/brand-assets/icon/obp-logo-icon-colour.png b/brand-assets/icon/obp-logo-icon-colour.png new file mode 100644 index 0000000000000000000000000000000000000000..a71b944947239bf264de1e4539d07ef5f61371c1 GIT binary patch literal 2687 zcmV-_3V`*AP)n-f9M=!8hU@ehv-y4!zN~VT?W+<)w1N=wgXhi<_=KUWESP}feUI*MC*8#*= zfUqtFb`ZsSdf1yAeyx54hJyaa03$&B2+`Uo=>0Q<6+yrf;lF?01(aJtV3!F48$c1F zz#%|<4$&WmLGSPymW0;Hn-T`tr#m_}$so$v#+yHS+wy-#p<)Z+|nS%_Eh>VC&60ummgQJw4yu zC#C%g?f{6uQ;3|`HkPsxc}m)3JRldsj^$hyEJ>k+hVTcwzHp)scaAuQK##2Q>C^M) z`(S`Zl6RMuX*qC+(WDpjPHX#zwaL3*#HHY69Lpzl^;<8W32Syn9Vt21Ve_ zrx*5`0JD~rz z%ZF;9{5W<;tOxWYVF-s1PmY>cIURZzI~%}r8%iYI)R)2L$)RqH(4WZeCz{3sTRlcF6PZOeN3%p1wRXOQCa?wu*7!r77f(ha^eHH%o!@b5gL8dsH%#$!qTL zyY376J=Nn=$&(Sh08>QcQUj5=K|fIv)e>XgJe@uk(MB?r<=Uup-SahTa*zcG8EmTjW2O$`N}mJN~4=L0O&YaERT&ZvwMD=SE^ zajIXGUN}bMvc!UUjPA%SNDW7#^N`;_FhU%i>khLer*G&l9FWGnJWo-wKrKl(-Vi7Y z4GZ~|Bc0?Yj&5X3UfTJfya`2K+B?!1>5|pz6SCwl7JUbuP!PwHZQUGcfX+Wh{>5MW z0vvW7yxdu6Ga%rM*A41jd32tB|M!QSK691%HH|35tp76)b=NT}{tk+LshZz|8VEN3 zbxMCurzEnoSCVj$Ah!(H))aAk!+FCaN#@r$6eBA%)DCc?t-|+tsZ!Da8iGkAJ@=%F zZ)s`}=O$B}xvD?TbgpRpQFmO@iDSWAHUbY?>fgD;bz9_yq$_duMiMrfRqJsX5owth; zk~%1<)A^PL!05(f$EQ`v19##zIT=b`N!X+F(c<{$K3D)Z zBbC-LlFHW*tYI8VL^JXP2(QAhu<*00pxke)$T>6JJKs4J4i316Q8t%F=~E%8EOl1P z;gy~=h+x5?4hXkV`K*=RWv%OB5qgHZ(*WBD}6|o#jz;%1;U}~B1ojHo>6gOLwA_2x4E9r zrP2~VPotA6!(Gz`;cT7S@bn8&n&6#$gr-yH>NSeIRK%~r0HQ*|QF;1^%jgU(iJN5z z4Iy~rUy6Lkz$qAkm#*H@sqcv=ycAo`Aj@{?qtF|*=83Rth|vjv-~hp(Go2FBo0W;ChL#@n!BIM>KDlNhiOzVH_L zsws0`0%x1Y+mv(rBk4k)r4iK)#5(#nW}W_YgCEV4=iVNK+a|5|3hb{rE;k!i?h>6xO1yQ& zFIhsN9u_ri7Vg6^^}v6f9D_L0H}EVIDw%-w1*1sn7_xXUH0or(H4ks5lMg;LDI3}b z=lh7b+WSk?mWTxgsE|AkKTI}$%s?&S=#%MweiQIMG}4{*i$ENTX-WY?_gP9begK>M zSzCCvi4LX@$XT>pGn8x@?R%}1?Xv8NrRSUEk!*tb{ERNl2ZIOXsO4HxfYOqeoXoer zaHN&JXba9@gj~=DxT>?GCl9p^B8Cl9!ZK9d!Bk3~I=OSo#fj!yk7|L)Br6>z&&W%& z+*u&cTdI=R57RC3Ufo<@DJbsd@UhC!O5`z?O5m!HbgEX~rRhLL{P*Agx%$&z{>+Wp zUAT{^NgSgxd5n8YU<2Y9wa8=KHv-kuHDZ;C`9IV|Wt}&V)CzO{&VxG&lYPOA#^f}+aoPQYvg56%h_4uy%1e!T^f_8piw4e-=r0{ zDq1Hm4}M4%CrBw}(Y5&Kwony~WITj%@m}4wuvGH$m_E8q#Or{L2cQ~yIz%c5`QGek tuBHi=su1{xAFv;jgt@;PH7Nqw!Y{~q1j1ECznuU8002ovPDHLkV1g?J39SGC literal 0 HcmV?d00001 diff --git a/brand-assets/icon/obp-logo-icon-dark.png b/brand-assets/icon/obp-logo-icon-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..9999b49d0db9578e108d5ef7383184735454be44 GIT binary patch literal 1533 zcmV;z6HpfZ8o3FuCc*9n+R;B*2q6F8kf6-v@ZB9Q=r z=fj& z(YI;zpB2V~T7&H-kO$7dkg}Is^gJv?a!$;MLS545qMmy$(vmw+Y+bJ3Bm=_8Yv*L8Z)W?7;CL&?kgF=WrHtiaf6xf}y zY#50Zu=@eTJA_|(UMJLp!xVF*&y7@#V12d;1F>X!h@Z#@ z%>heLF`DtV4fA2VJf#-G$}3!&Xgc;9!CpyPEySmg?auba(1zj~Gv z)3&4DXOl)KZ{-$$1ih5~)}y@Gva&wqWu)>Ff3Gv0uy%GPag>=w>VLGAU06RS#P@ag zJg-Ch*iw6__JHCWzM+`1ra5^d z1^%mq8%O!MrylRnH6#j3H}oU8A5ccO_n7-r`V$pGKLzpaJm_091%*OCY0teS!`6_( zp%>*I_wAG)s9VtUa#D(KFst zzo)S`;p(X0(jBf-QKDx&BUfgsqA|R?^8MExf4^rMQuMPsD`UKC;;9_pcMS#~nT8B~ zF10-Gi{tyQGCt8yO1#IU=zATl&Ybx8cX4c_b@g|b*0p8=o)Ct1U-vXfUJy^^am{7G zeRimu0jD5rJ}8WYhViDNq7K0!o6*%?{$M2LG@dxVubNMjDc?V0z7b zxCW|!xAZy?4pV8Rhpr4A<2^Y{?9*^{FN#Ug1^>{1IwzEfvb$H3sn+ zxI*W88<$cIZ*Z+MkFlnHc*Vdvl+8UE{wtM`C8;S#T#B;*#m8hwy1a`kYNLKHE8NM5 zK5~Y)C-YIP<$Wc~d?|u3a$DX(wDFc`Kq0Ju-7pZXBk*${VrelE71E}X0JP87=FI1X z3E?BU5QtXz&Sga7ZFhv%H6xM&RE&1ahzaA6pqzFJMHv6EvN5EYdxdTdn)_11(pOz| z5?7(V$K*}_S1{c%`O1=iIXou+hHsC_3woH1ouAR&X?uA=Lo6we!aOd*cB7vn;002b|WX@`K9{8VW@qz jq~iIVD*rI!i}n8lPRx%XS130h00000NkvXXu0mjf)4<_v literal 0 HcmV?d00001 diff --git a/brand-assets/icon/obp-logo-icon-white.png b/brand-assets/icon/obp-logo-icon-white.png new file mode 100644 index 0000000000000000000000000000000000000000..6af838f0a7118cf3c956274d1ea031c0257a50ec GIT binary patch literal 1759 zcmV<51|a!~P)latxTuoKmzNc*6ETy3KJ39 zE*nf;h`NI3wxUzz5mW2VTp^e)@=;h)2E^J{SA@0aihegOrcR^Nsn*U7CGv>`Xu;j< z!y|n>!7)mw+CKc@fKduH4u>Tr5Z!HI2HcwR8Mss7R(IiL%panJcff*kLe>z zIf4ZkBfKXvllam!w4L~KdTS*{kx@{})<;qP2O8alJJEu>M>%*VveM}i#&M8RKejW^ zY(5+0L9T^irdlPaVQx6PnWOVuKrTdnsESg7d7`bEVv;|n;>Ak*%GSV zJZF0fQTnhUITzYi`br64yD)T2+r=PGZJY1APK_?(IU-+fK^3L3)@7ZNr+^jYL=Cbq zj)sLTJJtieLEz8;x$>y`GS4yA1-Dw{b=mWncH}AV95U*@%vX>T+6v~#@3Oz~l$oP( zR4%_=XJ|z-cntc-cag5Zg334<7F3=y_u-fDtV9*bSo$u2(ZHqL_D90=AuQa7KUzQL zu*d%GI|-vjAIT4E)3rUeJt#hWnm32=c$}`@MJ7N#{&_2^ z4|nZM=aA#8xNooyHg9ZRH+cWR`Lpu;?-6-;YG=BDoVlmseNR1p^1T(}U1%Q28^v(s zGF?H&Ye2>8j{mMm2m<_WkWbHcshqa;7VaTrid1#{{x=B;T)58dp!n>BC!N6e3J={~ z`Z;E9nu=|i3(Cv<6HMQgs&HRqrt0{8O~bID{2X!1QzWPHoImdPeN8>U%tb}r&9=G{ zKE*i;FNfjSc+Pi~@tykNn|^>SzHUK<)|y3uJ{j<7>B=4zZH0JEag_38t6bgITM)_N zarMEb7*5GQCMPj=%k$whF?*!HXw7^ZyKTUlF}G9z7G9C35{S=4VjGYr;TMu1ME&1; zDxlei5?)ssy~5))5S@BGH4*$pd3j^uH6%xQ&Nm&uS2Pf|<=#_1nxR_WkB;AYzFAQQ zU>pxVvx~>RT=)C-$qvBM41HtQy60SyN5ui-Sz#B^y)Q3&uen-Z<0>FoO=m(Jp?&V4 ziXuVx%3BrizZ_h-3|Ej5;*whR{T~tGT<8xW^DccNPcZt4v-6~$4St%(o@@G6)p)F` zbslG37ogSB$SfeKj!U^4`(_niNzTj6R!osR#Nt|yDZRcq+bR_GUnwGw0hwa6NsaV2H3H(Fk!I`FB{GdGWPiDA zpRpi$RX;|Uul9Og{0@$`N1Ao<&VJk%R$Gw?|B@o!f&^o3s6D6H(ZF`vkCb<+z=XKP zF1E9x_|tn8^2Ky4X@Vfl#tT~uf$2iI;yRWzA&|DeByJ}V zZqPIFFA0nlkvbHm`5TcZZf}Ph0=e}}-w#mb^4z~T0ZY~n{e!OSeZj*BBEbWLcX9LbD zf4!Ex!0x*!<{blEX;#^a4x1psF~Q*$){-wdnV$l|f@6YPo&$&bijXM5G0}lO)*r|I z`!yX7(Sj3$yt9xoU~}Z0#sYa>!!ZVI(kL1mvF zyj@@hZ+@x1o7}ry-E9Yeqp?TlFmTR;m$6=~{~vEWvoGpE;?V#A002ovPDHLkV1mB@ BQ&#{0 literal 0 HcmV?d00001 diff --git a/brand-assets/vertical/obp-logo-vertical-all-white.png b/brand-assets/vertical/obp-logo-vertical-all-white.png new file mode 100644 index 0000000000000000000000000000000000000000..491d2d46cc0c1159746eba6de8d2088de9d0b229 GIT binary patch literal 11518 zcmVjU+=m9ueX9lDp;q2Wh&UDf+!WlsUS=R>r`NE1@TrOZUu2F5K}>NE11hMhA|`a z10(?wpyoLTQ7lm;MS>X2CoZ4_gNRJ^a#gj9)m~_>lcFeN_yvhm2_z2f=HjC&kmABr z?O5HPTPawpD2g&jvYL{b>XM>_n)DP!aUeONq^7#0DXR8HO?rx=bdi)$67A>~>#S9z z_<_q6eW(P}4HQLDPDn;5iSI}UvhBcC$=|8gq$o<0=!KFv$yqzfplpp2RxsT`Q4|G{ z3nekveS#KE-8viu@d1jWoITMBrAi=iFhdUIT;S^Cuhg8UDCbRdLP?_QSUbv~oEs%H z;Z{-3ny8Xz#eeh}Dv-_bIGMg2^Qa)v~qDOCcA z7u{2Vq%_H^e(@I+ML8>0*OWB>NEu)c8#ewOilS_uRfUp9*eQ;1WZnxEOp3C7Ruf7p zkd%?AU{aJbU=^XH0!f*O3MNI_IExD<6-dfVR4^&ZhFMf7sX$UZp@KtWEP?B19T&cl8VM2C=bZ<$IUd}E|=o>}D~rmYKWiSbq_K0A(e zJMg_d^>dKzc8&M4f&CbSpyx+!l>^xepS)?T`VAQkceYsJ!S-ByX%CRSUk94UEwv05 z6V9b?Fb&Bq1scrgQP>~IKi3_{JWl!E#NT%{aIC!NyD#~3X$RT<*m&=?Y}XV@hm(Dn zlj9lqMo#n`;D?Vd{3ZuMvYjPG;dB<2#mSE_I^{q!1_cVQAr0YGiy%9gh7_{A@5_^8 zW{%&@-~k$b#=kl4CztIH9oMn-+*^)syyhv9?a*5v6be}X#jjP%x0=Aow(F|xs{cN# z+TTcrYufN4fn;ayyfQOC{2Ba`RqaUA2lou?;$^uLvJ*1CzHGh|uq`Svk}j!s$64!_;HvJ#EmxM33neFA)|V z+>eP~L!OjOPOK(ta$0ir>&t<^yD+fh?GpGBN_BcmCfZUi@0ZPuya^?lU2P?jtI57< z&p1i*oTIy{-+o^;)|x&de)u6;>VaJZdRz$8U>OV_FHym?o1!tv7`Dfct$Ked+hQ=Xw0p(GtQ$F?Z&D2z%d4^-EJO=IuSp3oZ;SF{oT5u z?>eyu_6cILTQf1(AYH-qmy17y?CRG!mgS>N@O*Ha%KZ@^eAC+Uq#DbAFcXpg7ckh)}*k^TIE@n&`d>-2wt zdHjocH82fq!ndmi#M|@0pLcCA!^*?gwW!+HsvTj=gSp~6k>0@1{aFg0WN|Vytg3$Po`~%sXn#+Ox)(O)`8prj(euwZ<*`3CvD|{Q+ z*OA6?pn%#pJq@28!xTT-!C}9~VLvV*;m3~eHs#3dknk;-?cS-WZaQ+^ULkHD(|by6 z(!mchTEb;J;A>y^fC2(a-G21AvVUTZfZO?ute|N^Y9~_Q*-SWLL8vbIH zLTH|*#Bf5c8F9RW!#)T1M>c=Xv~An>Sz{YRbNVr*VY2DtKx(e@4#i*Z&{gwT6>=Y# zj^wEy)3_2rrehyNgb+&El;e{7HD0uI70nk)59d7Mcn63587#+#|<;A3p+%Dn9_#&Tw7p=OMXvTwO0uS$t0Z)Fyq`IGu`aGO-WOL+1+Do-mW zrf5p3O5iuT5L|fV8kdNdw~=d0nZq{a&{{V$$T6&WbOsNs#8^$nWw7QzkUON!EhXrt z7$uaXBc>>QSs?A9EJe)P5(}i5OSAmxuuYCYh_>q@@W$pkg=FYl%9FqUz%VfvAub%Z zdnmV+h{^f~V>Ko3j>-l2paN-ec!n9X9sWAv8KiO8<{yN`MMKCC0b-&W8gx$+TU`Vy zYZ=EGjRPb?xj8rq*>&Q_zcr=$c>6EPFmi-(dec;=sk7P}Kh+Teiss zgZb>)y2SM};Tay+s(rJ@mpN?Cld$`MCSR|NDKnImw5_<0Q6Pybox!eSawY#a@JMwG zercAPxmzLOk7wI43ndP{{ScEQ+fjS_f@LVtFCbSp36Tx;HV&H>jsGuw-rUcMKmvh< zookt|o9aCNO(Af@$$U2=S|t`rxQDj}g>2#&=Th8A{s5Of;d*8q5U@kI>skvx77Pcn zIViVFIj9{&b=T$^abX;`S?Zb&3BT2Zk~~j581Ec5FHbB72Fnvqzca@1z#usQpezQ&v$<6^$c zkq)aQZ;FYIA2P0jnpW3XsI*xsJjOl6)*}&%| zrnAJD(iOzA^hD{y5hQ<8ZzHyNQZLCXgdil<-vTZK_VnCtKW0&Tczhg zK_UTThfN=`m@`WBo9MW%*=0ySm}4-^9FtUruym$^NS_=EcV`OBWAUihk^Uuv6sz-4 zT9v>T)95JuWjos7=sfQ;CrYOeWrzTBTf8nuKpfBEy5)OA+{$)G3PAAzU|2k}wTEuY^Hsh6!+eT-?>+>n?kPbNlBZGizZbYiM)CAiquPF{2 z`yCt#q9WX&nLBKAn)@>rpGAA?;HKvzOl2z9+9V!b@zhX7np- zM`L_!BD@NRP2W?3Rkj(KG(??`)J2!3l#&^zKtLKAlEL;dIB#N^h`Fq3#C#%uE6?(8 z&mA{+F@N>K0S8sviBK5>Z_irM^~@jwmx*j0rDl*I61bE^y}Kq|LMf#%@X&clToTCO zJO~!il?Yi3CcF_dc|KRpGn+MutiI0)-+RZ+7f-tgfvg-*I&5l?uwa5s4lj`qPlOVs zRl%VM`GANyBg6R+LMR!eMJ!eVSJO*f)9iNaz{%T_ts&0aah)67KMVgNrXEBlYnN;$BYXfi|I(Ar1ZsIB?_$H=q`< zVZVQmIe+O;JgBgFi3chJ&s68*Tgayvt;hp5pLc;c;6ZarpiRAWi4S<-&9Ol)?1Gf_ zH6AOhr(R>uGrOsdy>xV34y$w+M9fd-J5l>rcl0{)2>}qZT?iA-9yXWaMb$oz6{r%e zbSDu@&#Je$`E+9L;U9xEi6;6~%w-%tU1A-|s=bOqa}32w;nS=I=U9+ND1OxU^3LS zX*2|rw~5UFb#iKnGeMq#=U7hoU#`*5L;#NoqFIz5LP_#7Qt<(V(vp0d?cTAY8Ve%Y z(~W3PW9nb!@C-##;B*EVB17&k51Jt(sUAXzB{_9UwS_+BR2E+Z5yE_p7a6o$gucr) z_o2<@aGc<*HZkD@JW7WQo*|pF;fqCuQ6JU0aFkGACEz0hQ0@1I(k9dzQ(hq3U-#q> z?jzh+Ci|2f&c`4j=df$Z-aI@V{b1*J8Jcn`)Px03NL#I_GyJT{JuEREN{f@q1$@nn zycI0Z@Z?xus&?aYZN;tf7sMo9{awm=dan>K!ifFB+?OsJ@8=boE-_ocoXet%(Uz4+Lvf7O9+eaE3;p`F14WQ+2;^rZ|7!Xe?Q z1Uh)PZ_>OD_yzuH7Me9xtCu8rPZ6#~L)-Qx{eU7!pe32n!kS8#I-i_otPMtXZJ<4D zy~+&pFnD|(45l&kTi9ggOGE5SkKp^aYx{t#+0LM!xx$(63u|0AaO+EpMrfxbltM7) zx0mowTTYCD9fjQ^o{pZgtDA73sK*b76bb%Oc5R~zvG(x zEYdl2ue$QzT+Y#8B8~g98}YS8`+rJ8sf2&#{Pqx$DcQj1J3I=fp>2b5E?SbX+A|{8 z`4grMMBGQqsuhiPk8ypMfBUFGnyi_Pg{VO|ath%05~8$B%!SQ$umZ_o9E+|#`wPyS zXf&mfLS|RB1FgG&+79Q#Dndy#lre*Q30oZaP1T-VTwMHGHGX~3XwT(+UQ6vo)m);^ zocoyc<2{zSW}yVr!*!3rZ0iFt{c=CL!FbB0*A!!+WT3>rlm~!S@tjtu${}kq-9rfp z&uopub_MK$jVyq!tG0IuJLdIss} zY#)Q=$p=2?`_95>ib>`PBGk&BO?sadF{2r{Q@~DYY7=AKnr6k)abJ9Hp|qO6D6RUS z%}C8D0+svKE77=D8C%ZXrxX(|+D-{O5WR|}91NR^;O3W591&C5kRRt&EOqPXv9g2< zC2Sw&cGouWHOz{xJ})K<=wJPmihrpQ%rHfe7O<1uJFLimpiITUFJ;eX52iX_v*G~o zfW)i!8f=pw)kJ!DIq><1K69P;5jfYlN8~8CYN0;{Fv^L};?MxWhC)vUY?BP@yy(uQMlpP^*qr;Hg7$59>FG z)^{W4&OQn6RW2eSgg>*7KYKnaCCKCnB3L*nGVA)X^s%;DZ=K(fWA^pGAtB1#iS{!Z z{=H-i#}1k3xI9lb$G_GCYhu?iP7KAoI5lmctlFDE*K87MzMGDLf0_eD0c+-X6SF|7 zkxu;)iVw1$&swjs2qc%v3H*6o8KG(J>eECztCxsYX3$TGnZ8F61}<8Vi-4TUUu)+F zo-!l%h$&#rbp;}Vg;EJ+3I?7L{|uW+W=B-5L=KI{n}y7J`?T<)#*kP$Gxw+&wHe*>nv$mU8{*;*1VEA-WAz>NA{~7B zQuo`U&q4jYEx4f?oOYN#N#f)6`4ae^_9agaJqP85r)43ee+}*HGO*AvuqM(qF_>OY zu!P5`%@CZs`L4utE@PN6#fEhD0^6d*a`XwMu3&?{;-7Q58<9Ytt3Am7n(UkCCS32*dW0@M4z%^;b-z zguCx6kIld20HxrcW}%dd55T}bCFT*tgqJ$v3)@WY(#5hlVSNLA+47!2Ki8Pv&xgtt zxd2GY)`yQC8=%&T%6J= zu=H;*J)gCmORu@F8e}>DglpSzj!l7aoU6vm=?$gBcKqxwUF&Zyx3P~|DVguQUq}x# zU_Pgirjulm8SNHA#7o3On^5X|F22_PktzveDkOZznXa`aeb=?F;}xy3WcYOWo~|9J z(F-TfmPiAGFqAX*%?{53N*innan1IRm6)`>vFGxm5{39bcrY@K-+j;ms_#9Y>4$h9 zp!`qp{=~rm)p@g~=pt~~EOzfJ%5VW*#+nGsuuw|I#KQL50QP-$oEl6g3v^z=k;FST zv%Ls${!;0IwWT#9mF7|+Zpxm|Udr|7fi=T;4D7v|llYn;QFzdnyfGJ{%G>_C@;Jcv ziK~HR4%_l%;V#B;i_LdYhX z-_xmU>C{{&7-i-Y*Fce4vs}X}P{M|K^RveOFqh{oS~Ffs(RWM;|BVl!yh|ktr?KeQ zb>nS=K!SAxzNJN%f*!z}z9Bg$p`YQDHQiZ^^)F%hYxPs^k!U`y0V)sm{16$xIEGlp zf`NYu_#tX%XG1&cDn}DCTr_2EY3bBS^TYV|kcZS=zkaXD;lr6wDF& z95VhKRvYN!Y_%mu26|WFANpFAcr@2_k*pc77D```1{uOozzE zBI}MAza(-HjZdmP6iADuwWOhAcIL&6SlWU2zw4+%&-*BQz8lKCn*<{KIoB%*Q}hHg z-W4LCUgWPpIvDsLEO-|0%O|vI&VmRO{w;Gl*sX{Bls359bcS?T!-6o>w>$IVco98W z#E-WzjgR;~av%TuqGvgjMhJh`v%sOzV<$Nw0x`I6uVRs44Wzn89C{sf0%`*`%kt!O z=z7&wlIMs4G4rHC5H+*f?}7aBjTWADRL9Fa|Ari%A=}TsQ9AsI6`V^@} z@IAUZImQqfJVSPY!ankj@9hV^cN}pJH#Cpij{Rm1-yOBWbG7|Ta%c5`tq1d+%!t=8 zRZJmNS$bb4Jcef-2U@@eFP)e>>D2XAx_C>TO{IzTGiI$ErGExKADpH0jJ|Fdfx|tD z!o(8+ITGp1)jee`*)>RG^}Mv^dP+VA<&cE3Yu~^xwP5n@Vho#Yh6%pA@=Aud4+aN_X%SIWo-UZ4CHW+9k`_x1Gbt%DU}&)kj3?+lNIn!PS)Iz zVFn_IcA^!rFv<~rfOs3AbKW5hR-2?bCLHsmB$U?j+r-b#3eiH}AOWOLxt^=RiUC_q zp;RCpLTO8(O7$C9t?6^sUb_q?B7}HG{I(&8*ld$wIVUe4X9tf26#l*`ur5QnN3|yI zwb-dl__Cf&VlTV2C?ic{t0F-z(VOvp2qlsFCDI^>1A2k5Wfn&Jsn1IOGW?h||IHyLIk`xtVfI|l=t3zF-Vilr$> z7DI>=yC=RL7W?j_v%V=BJqrD6XPS4~;X7vqA2{j(X*C4$djewFyq{BrVl zfi&P7AH;;0Xp(1Wd|&I>XO%GxysoRDG6wcuv3C7n@|7^(Z{pXi8^LPgto3}tqc%Q7 zaS`EC2QeVmWvEnB6ihJVEh=bFg;FX`XvV88{e)1yrBun_kpzkWN3+DW40c>apu}`| zP%(`*l_^gBHKNm2iHI9H*so0v_dXTMhu_LiLMb69G?Rsr;E8pvB?yrl7PA>Bs&n%s znC9~CR7-LwW?Tjes~^ha>N@6-u$nE*#J^NHZ2G@Y=iovkq}_#YSdd>@-b=}5yhAv0 zq!aiNnh-SyOT@{2j3z2MCoTcAbe`8kC~;3k>OQMNsEJ))Wa7TZA2?(Q@+ClBOs-w$ zQes#z?csqDOLDARu$$aUhr~kA1=z@~*_+Hm_JCesfUhV6Oy2~}?1K+)-ZoEf7l1){iDd)NvHaKA&MEWG8VCk+E0^Io>E!K3O69x$%4R z5a-;uKSwzhzd8wPIj2(&6rg{~d46g+-VIjZqU}wx(lM1qy*MK11p-1gc{Rg%^USAN zi`;TzmgZg-yh{ohQ0tKHKQKO+fz3s1PGZyjU7%D8Wh5eiu3!UI%Ft;FWRv=@Xm zL6=Z6NGnP1`9;7wW%AIosnFm|sjlw@JQ8Ui+pV?ah))`W8Rs88tJ;50Hen|_Uwf|3 z5Ty@IIbS*ln#8mNc5Vi(K_w5m>k6)0!@D*bsZe^R1GJ*7%P&2j4TW9C#Et^{0wM{^ zU5h>ZV-Odb7^)}{&?l7kNT)126MQAehF}V2DvXeF3nncG_D~dMbvlKTIJr_9j51oF zs;w-X#?J6_srnGA?$x5DO9nU>!K*0I^UJ{JL&;{bu~@`lHHhV~$&4ko%LggVAA&2y zLjgrmqM%nOC0Lv^^opE6!4ZE|( zy{Cl}AgjT9FVSf%px6vlB3%mD5c|-Ha|Us+*8m4ai3g|L6V7@@8$gEgavI#?GAtRoazIC#(;x2a5+Sbf)|SLJNzD0vY((bO;_TgHRS>Tvm% z1I59S?24rvPQU1cgW{=s7h6S%y008)8ue<%uN{u@nWn?X`9u?!VkcV~=)IilX^A90 zuiL7<#6pmLHV<;_zeawST?RMu-Jx<%M;e%HOxWK``Q9b@A6t%Ot2``}2tOiuSewF|8^(fbzRZ~2P*)J|4SYft_$7@r<{=Ty1 z82l_7_@RKUB3=${D1Dv>{S0oDRm($0MpC=h{$a?UO4bs>zh&&8U5X#NZGRl7m0=wG z9P|Fz+#X_$xz_UyiF4@~N~<1TO$jTQk~3?_!%{C`T{=F2B=`PAT zcRW3XKk8*C?gAczDKYSzRfl{FJ8({+fTQ3WOUJm@IS|VPopU1RXmZc$-y-|tR2>WHS(>JU4=4t^orfLUT*#B80$DLdI@^rOV7UXk$Hnk>P!fK(u z&75rNcU_%!9mq=dBc(}drpL`e@n}hlJ8xa-IA7!NJ$^QK)ct&BeaC$8UIciGO-*=Bydn@N}y4uP9-aG8ikVhd*?R_Id>QKBZ zhPt4FX(cG0eiOCU+;Prk#P_>QOgDYUz(}ZlFhBKO9jP6c6=J!+bh!&Mc!C^+67?@g zIa&*({?)y61cJ6Ilo(2DW!h6zFf9!EvBmqsAYy!+Gqr0npZku1kx=_U-Sn4qO+xY! z$KZa`CYZcwOXMb$4jsQ`hY3)(*5$*@*>~Jy%P})-0kX05DWigEAt)}BZ;r_6IBzp@ zA6G5=IQAU_BcXQl)1mQsn*;yzgyVFX3Nv_s280rIYc)A0K=yfPBe(`ni3la z(tU|=(@>bIu}aGWmd;c>L670Hu|&D;&t<(N(PG-d?*GLwERZrx)crFCR%>D#_D2;?;~`s0CP!R-;lmd0CE4PqdrZT%Kl9)? z_TKS#TqbkBVLGPoL$ZNOx%^pHf4{njtL9}>=zMMYTz%UOCIvb)kYj? z@;O2_8k5a+EYM?mz>16T>q0F>j%k!5zWV~>*8=1F{YT3&PcfK|X?d^Yf5cpwpKR;X zr7%~xeK--2ZCa6m@&g~5(w5A4HKuBN3HuCMr8s5CPcg|*l$a3h6)^_mc_ao_JG?^nlf;b-d;x${uMXUs7i&yCNctAOZf ziJ`W>F%1=F|YajCm%bvEKSU5VGH zmA2c+9roWfk-j=knm7(ndxeN^6nI>ra}-Jp*$3ks;6dOeHV!>%MLtUGFTvua z_I=Xvn+!bCc7T!J?;6Z^jcvLHbAE5|xuJfoEI--y&~corfn#({u(Ffwx&-WzCfpn9 zp1e=m--zQLhkom3p|oq`jpayId&;m8$POr6rT9Q;5q!28nvUOP;E}ciU?kLDzwP#9 zo4n^<-M0NM8a55LRq)<*ZA&d?Tsg+45&Q2emGw=|*I?ZBI}nY#Hglmwgp%{hvty78 zI(O}n6e(fz5&lkXy&kz|b)HLxG|*$#?g*1 zPPXH8kv@&3#ACGHC5D3(p0|a2Q~wh0C2W8J2QBD1HHF}ZSSTz~4MIpBu_~6Qctj3WcGU##r9X7WfDSdKmfR5j0 z;E}ciU?kKYU~p*Jj?HjK^24s@e0+>Iy}n5Dv;4}z^Eo@4tLdE0cQusu+DjogkQD;_ zK+g?Z9djRy9Fqm)Ep0;xHQ7ooQc6N8YuU}AW7|IekPEJUs5#?}({dPOpQE{g#GG84 zBhABUETMBY-!&mSgMAOBi&{nhBCjPL%^44`UFtd6Xs6l?VdVdp@_){tziPa{Rk*Co zb8G}^H+TK&vvf{%jBoh4PjEiXGmJn6!qcTFxW?d{p6}$aIXMRI6nI+Hbm+Xsmbv1J z7tSHsB2?xS8~*8j%Kmb+=VQ6>+@*pAS;3o~yzSLAoD6h65qZk89BE_W`SjE*mcuh- zwVb2ue=tg|XFoS%?*&=SfHj&v&FP!$JHrIz7VAP1VEDadu)(>Tp3A2{zljnkZ* ztpsGvg-4fjS(D(JQVm~hOrHrT$|)r_T0oZ`*CYBK$_Y(Y%QmoD(qt`626Y2lC4AI= zZ;UH&hivRna83+iBZ9%%s>T_qHLVSg%YEJ zNtu8OBtT(VDoZ>l@g?Pl<;IQytWIq$tV=lGcvU#CYBf^Qx7X1m$ahgj36-J7p1mJ9hQj)G`3a~3RuJtqoLf+X|3i4XBXzw`xR+ zqQrm-r4cl=C~m(atw4#U5c;A%B1KVRM1|4}Dj~(e5iFQ8SRrK)hoCB9ml*#luZ5cA z6h%n^6-oh+s6JD*->SyTZ2T@C8Tc^)ewTJHuPl5@`MXq8oT4bHqC#l}M8j&fn&#hS kPx# literal 0 HcmV?d00001 diff --git a/brand-assets/vertical/obp-logo-vertical-colour-small.png b/brand-assets/vertical/obp-logo-vertical-colour-small.png new file mode 100644 index 0000000000000000000000000000000000000000..4ac4129f871186c4578655c045f1da653e6b6db0 GIT binary patch literal 6220 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 Hu0mjf@?(xf literal 0 HcmV?d00001 diff --git a/brand-assets/vertical/obp-logo-vertical-white-black.png b/brand-assets/vertical/obp-logo-vertical-white-black.png new file mode 100644 index 0000000000000000000000000000000000000000..d00dd3504dcb3fc0ea2a0d1a1adaf7d13a6694f7 GIT binary patch literal 16484 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 literal 0 HcmV?d00001 diff --git a/brand-assets/vertical/obp-logo-vertical-white-small.png b/brand-assets/vertical/obp-logo-vertical-white-small.png new file mode 100644 index 0000000000000000000000000000000000000000..3e5c7a01221bc93a70e2cfda49cb6a59e8ccbf07 GIT binary patch literal 3899 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 JPDHLkV1j4VRWtwq literal 0 HcmV?d00001 diff --git a/brand-assets/vertical/obp-logo-vertical-white.png b/brand-assets/vertical/obp-logo-vertical-white.png new file mode 100644 index 0000000000000000000000000000000000000000..356e435556a40e3e10d40de9a468f727408f224e GIT binary patch literal 14186 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 ARR910 literal 0 HcmV?d00001 From f5cb359d49f0ea8ea9fb153480f30c48d8e95d89 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 25 Aug 2026 05:32:48 +0200 Subject: [PATCH 3/5] Dynamic Resource Doc Glossary Item. Dynamic Endpoint Code Generator --- .../DynamicEndpointCodeGenerator.scala | 28 +++--- .../main/scala/code/api/util/Glossary.scala | 85 ++++++++++++++++++- .../api/v4_0_0/DynamicResourceDocTest.scala | 54 ++++++++++++ 3 files changed, 151 insertions(+), 16 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala index 63071be975..b64b085c94 100644 --- a/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala +++ b/obp-api/src/main/scala/code/api/dynamic/endpoint/helper/practise/DynamicEndpointCodeGenerator.scala @@ -22,7 +22,6 @@ object DynamicEndpointCodeGenerator { | // if the requestUrl of resourceDoc is /hello/banks/BANK_ID/world | // the request path is /hello/banks/bank_x/world | //pathParams.get("BANK_ID") will get Option("bank_x") value - | val pathParams = getPathParams(callContext, request) | $variables |""".stripMargin } else "" @@ -30,17 +29,17 @@ object DynamicEndpointCodeGenerator { val (requestBodyCaseClasses, responseBodyCaseClasses) = buildCaseClasses(fragment.exampleRequestBody, fragment.successResponseBody) def requestEntityExp(str:String) = - s""" val requestEntity = request.json match { - | case Full(zson) => - | try { - | zson.extract[$str] - | } catch { - | case e: MappingException => - | return Full(errorJsonResponse(s"$$InvalidJsonFormat $${e.msg}")) - | } - | case _: EmptyBox => - | return Full(errorJsonResponse(s"$$InvalidRequestPayload Current request has no payload")) - | } + s""" val requestEntity = callContext.httpBody.filter(_.nonEmpty) match { + | case Some(rawBody) => + | try { + | com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[$str] + | } catch { + | case e: MappingException => + | return errorResponse(s"$$InvalidJsonFormat $${e.msg}") + | } + | case None => + | return errorResponse(s"$$InvalidRequestPayload Current request has no payload") + | } |""".stripMargin val requestEntity = fragment.exampleRequestBody match { @@ -71,11 +70,12 @@ object DynamicEndpointCodeGenerator { | val requestUrl = "${fragment.requestUrl}" | | // copy the whole method body as "dynamicResourceDoc" method body - | override protected def process(callContext: CallContext, request: Req): Box[JsonResponse] = { + | override protected def process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]): IO[Response[IO]] = { | // please add import sentences here, those used by this method | | val Some(resourceDoc) = callContext.resourceDocument - | val hasRequestBody = request.body.isDefined + | // the request body is available as a String on the CallContext (read by Http4sCallContextBuilder) + | val hasRequestBody = callContext.httpBody.exists(_.nonEmpty) | |$pathVariables | diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 2c4c12c5e6..6e0cc03269 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3589,6 +3589,89 @@ object Glossary extends MdcLoggable { | * [Introduction to Dynamic Endpoints](https://vimeo.com/426235612) | * [Features of Dynamic Endpoints](https://vimeo.com/444133309) | +""".stripMargin) + + glossaryItems += GlossaryItem( + title = "Dynamic Resource Doc", + description = + s""" +|A Dynamic Resource Doc defines a *single* Endpoint at runtime: its verb, URL path, summary, description, example request and response bodies, error list, tags and Roles - plus a *method body* written in Scala which is compiled at runtime and becomes the handler of the Endpoint. +| +|Whereas a Dynamic Endpoint (see ${getGlossaryItemLink("Dynamic Endpoint Manage")}) is created from a Swagger / OpenAPI file and contains *no code* (its behaviour is selected by the swagger `host` field), a Dynamic Resource Doc *is* code: the method body has access to the full CallContext and can transform payloads, call Connector methods and NewStyle functions, or invoke Dynamic Message Docs. +| +|Like all Resource Docs, Dynamic Resource Docs are part of the server registry of the API (see ${getGlossaryItemLink("Resource Doc")}), so they appear in the API Explorer and resource-docs endpoints like any Static endpoint. +| +|Dynamic Resource Docs can be created at System level or Bank / Space level, and are served under the `/obp/dynamic-endpoint/dynamic-resource-doc` path prefix (configurable via the `url.prefix.dynamic.resourceDoc` prop). +| +|Authentication and Role checks are applied to the compiled endpoint exactly as for Static endpoints - including the checks that run inside the shared authentication step: Consumer disabled, User locked / deleted, Consent processing and Rate Limiting. +| +|Some cross-cutting features of the Static pipeline do *not* currently apply to runtime-compiled Dynamic Resource Doc endpoints: API Metrics are not recorded, the JSON Schema Validation and Force-Error interceptors are not run, the Idempotency-Key mechanism is unavailable, and handlers run on auto-commit (no request-scoped database transaction). Dynamic Endpoints created from Swagger (the proxy path) *do* record Metrics and *do* run the JSON Schema Validation interceptors. +| +|Because the method body is user-supplied code compiled at runtime, this feature is guarded by the `allow_user_generated_scala_code` prop (default: false) and the Roles CanCreateDynamicResourceDoc / CanCreateBankLevelDynamicResourceDoc etc. +| +|A helper endpoint (`POST /management/dynamic-resource-docs/endpoint-code`) can generate a method-body template from example request / response bodies. +| +|See ${getGlossaryItemLink("Dynamic Code Paths")} for how Dynamic Resource Docs relate to the other runtime-defined building blocks. +| +""".stripMargin) + + glossaryItems += GlossaryItem( + title = "Dynamic Code Paths", + description = + s""" +|OBP offers several building blocks for defining API behaviour at *runtime* - stored in the OBP database as instance configuration rather than compiled into the source code. This item explains how they fit together. +| +|**The building blocks** +| +|At the *API surface* layer (what URL / verb exists, who may call it): +| +|1) **Dynamic Endpoint** (${getGlossaryItemLink("Dynamic Endpoint Manage")}) - created from a Swagger / OpenAPI file. No code. Every operation in the file becomes a live endpoint with an auto-generated Role. +| +|2) **Dynamic Resource Doc** (${getGlossaryItemLink("Dynamic Resource Doc")}) - one endpoint definition *plus* a Scala method body compiled at runtime. The code is the handler. +| +|At the *Connector* layer (how a backend system is reached): +| +|3) **Method Routing** (${getGlossaryItemLink("Method Routing")}) - a routing rule that selects which Connector implementation serves a given Connector method (per bank, per URL pattern etc.). Pure configuration, no code. +| +|4) **Connector Method** (${getGlossaryItemLink("Connector Method")}) - a runtime-compiled body (Scala, Java or JavaScript) for one of the *existing* methods of the Connector trait (e.g. getBanks, makePaymentv210, dynamicEndpointProcess). Executed when a Method Routing rule routes that method to `connector = internal`. +| +|5) **Dynamic Message Doc** (${getGlossaryItemLink("Dynamic Message Doc")}) - a runtime-compiled function keyed by a *process name*, for logic that does not correspond to an existing Connector method. Invoked from other dynamic code (or by Dynamic Entity storage operations). +| +|Related: **Dynamic Entities** (${getGlossaryItemLink("Dynamic-Entities")}) provide runtime-defined data storage, and **Endpoint Mapping** (${getGlossaryItemLink("Endpoint Mapping")}) maps Dynamic Endpoint JSON fields onto Dynamic Entity fields. +| +|**How they compose - the paths** +| +|``` +| +--> host=obp_mock ......... returns swagger example (mock) +| | +| Dynamic Endpoint (swagger) --+--> host=dynamic_entity ... Endpoint Mapping +| no code | -> Dynamic Entity storage (data-backed) +| | +| +--> any other host ......... Method Routing: +| connector=rest -> HTTP proxy to backend +| connector=internal -> Connector Method (code) +| +| Dynamic Resource Doc ------------> compiled Scala handler +| code at the endpoint layer |-> Connector methods (routed by Method Routing) +| |-> Dynamic Message Docs (by process name) +| |-> any transformation / orchestration logic +|``` +| +|**Choosing a path** +| +|* Need a quick mock of an API from its spec? Dynamic Endpoint with `host = obp_mock`. +|* Need a data-backed CRUD API with no code? Dynamic Endpoint with `host = dynamic_entity` + Endpoint Mapping + a Dynamic Entity. +|* Need to pass requests through to an existing backend *unchanged*? Dynamic Endpoint + Method Routing with a `url` parameter (transparent HTTP proxy - no payload transformation, no credential minting). +|* Need transformation, authentication against the backend, error mapping or orchestration? Use code: either a Dynamic Resource Doc (code at the endpoint layer - one self-contained artifact per endpoint) or a Connector Method (code at the connector seam - keeps backend integration reusable across endpoints and swappable via Method Routing). These combine well: Dynamic Resource Docs for the API surface, Connector Methods / Dynamic Message Docs for the backend calls. +| +|**Static vs Dynamic** +| +|Static endpoints (${getGlossaryItemLink("Static Endpoint")}) are Scala source code in Git, changed via release and restart. All the dynamic building blocks above live in the OBP database of the instance: they can be created and changed in real time over the management API (or via the API Manager UI) with *no code deployment and no restart*, and they never require instance-specific code in the public source repositories. +| +|**Guards** +| +|Runtime-compiled code (Dynamic Resource Docs, Connector Methods, Dynamic Message Docs) is disabled unless the `allow_user_generated_scala_code` prop is set to true, and every creation endpoint requires its corresponding Role. Dynamic Endpoints (swagger, no code) are not affected by that prop; each generated endpoint is protected by its own auto-generated Role. +| """.stripMargin) glossaryItems += GlossaryItem( @@ -3929,8 +4012,6 @@ object Glossary extends MdcLoggable { | |You can also use these endpoints to create your own helper methods in OBP code. | - | This feature is somewhat work in progress (WIP). -| |The following videos are available: |* [Introduction to Dynamic Message Doc] (https://vimeo.com/623317747) | diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala index 70d4f035bf..f47fa3133f 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala @@ -42,6 +42,8 @@ import org.json4s.JArray import org.json4s.native.Serialization.write import org.scalatest.Tag +import java.net.{URLDecoder, URLEncoder} + class DynamicResourceDocTest extends V400ServerSetup { @@ -329,6 +331,58 @@ class DynamicResourceDocTest extends V400ServerSetup { resp200.code should equal(200) json.compactRender(resp200.body) should include("_from_path") } + + // Regression guard for DynamicEndpointCodeGenerator.buildTemplate: the template served by + // POST /management/dynamic-resource-docs/endpoint-code must emit the NATIVE contract + // (Request[IO] / IO[Response[IO]] / callContext.httpBody / errorResponse), so the documented + // workflow — copy the generated process body into a dynamic resource doc's method_body — + // yields code that compiles and serves. The template previously emitted the retired Lift + // contract (Box[JsonResponse], request.json, errorJsonResponse), which no longer compiles. + scenario("The generated endpoint-code template compiles and serves as a dynamic resource doc method body", ApiEndpoint1, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + + When("We generate the endpoint code template for a POST endpoint with example bodies") + val fragment = SwaggerDefinitionsJSON.jsonResourceDocFragment.copy( + requestVerb = "POST", + requestUrl = "/template_gen_user/TEMPLATE_USER_ID" + ) + val codeReq = (v4_0_0_Request / "management" / "dynamic-resource-docs" / "endpoint-code").POST <@ (user1) + val codeResp = makePostRequest(codeReq, write(fragment)) + codeResp.code should equal(201) + val template = URLDecoder.decode((codeResp.body \ "code").values.toString, "UTF-8") + + Then("the template declares the native process signature, not the retired Lift one") + template should include("override protected def process(callContext: CallContext, request: Request[IO], pathParams: Map[String, String]): IO[Response[IO]]") + template should include("callContext.httpBody") + template should include("errorResponse(") + template should not include "Box[JsonResponse]" + template should not include "request.json" + template should not include "errorJsonResponse" + template should not include "getPathParams(callContext, request)" + + Then("the process body sliced from the template compiles as a dynamic resource doc method body (201)") + val marker = "IO[Response[IO]] = {" + val processBody = template.substring(template.indexOf(marker) + marker.length, template.lastIndexOf("}")) + val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + val doc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy( + dynamicResourceDocId = None, + bankId = None, + roles = "", + partialFunctionName = "generatedTemplateTest", + requestUrl = "/template_gen_user/TEMPLATE_USER_ID", + methodBody = URLEncoder.encode(processBody, "UTF-8"), + exampleRequestBody = fragment.exampleRequestBody, + successResponseBody = fragment.successResponseBody + ) + makePostRequest(createReq, write(doc)).code should equal(201) + + Then("calling the served endpoint with a valid body returns 200 (the template's placeholder business logic)") + val callReq = (dynamicEndpoint_Request / "dynamic-resource-doc" / "template_gen_user" / "user-1").POST <@ (user1) + makePostRequest(callReq, """{"name":"Jhon","age":12,"hobby":["coding"]}""").code should equal(200) + + Then("calling without a body returns 400 via the template's errorResponse early-exit") + makePostRequest(callReq, "").code should equal(400) + } } } From 8fb6ee3b09b7abca5e1f23b974fd09421415a0c0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 25 Aug 2026 05:48:59 +0200 Subject: [PATCH 4/5] Add hash of code and createdByUserId and updatedByUserId to Dynamic Resource Doc --- .../main/scala/code/api/util/APIUtil.scala | 12 +++++ .../main/scala/code/api/util/NewStyle.scala | 7 ++- .../DynamicResourceDoc.scala | 20 ++++++-- .../DynamicResourceDocProvider.scala | 16 +++++-- .../MappedDynamicResourceDocProvider.scala | 10 +++- .../api/v4_0_0/DynamicResourceDocTest.scala | 47 +++++++++++++++++++ 6 files changed, 100 insertions(+), 12 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index a625ce703e..744fe950b4 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3254,6 +3254,18 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ base64EncodedSha256(in) } + /** + * Lower-case hex SHA-256 of the given string (UTF-8). Used to fingerprint the source of + * runtime-compiled dynamic code (e.g. a Dynamic Resource Doc's method body) so that a stored + * record carries an integrity hash: it lets an operator answer "has this code changed since it + * was created?" without diffing the raw body, and is the value a future code-signing / approval + * step signs over. + */ + def sha256Hex(in: String): String = { + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(in.getBytes("UTF-8")) + digest.map(b => f"$b%02x").mkString + } + /** * Create the explicit CounterpartyId, (Used in `Create counterparty for an account` endpoint ). * This is just a UUID, use both in Counterparty.counterpartyId and CounterpartyMetadata.counterpartyId diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 62809729ac..88c4e98806 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4316,14 +4316,17 @@ object NewStyle extends MdcLoggable{ def createJsonDynamicResourceDoc(bankId: Option[String], dynamicResourceDoc: JsonDynamicResourceDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicResourceDoc] = Future { - val newInternalConnector = DynamicResourceDocProvider.provider.vend.create(bankId, dynamicResourceDoc) + // provenance is taken from the authenticated CallContext user, never from the request body + val createdByUserId = callContext.flatMap(_.user).map(_.userId) + val newInternalConnector = DynamicResourceDocProvider.provider.vend.create(bankId, dynamicResourceDoc, createdByUserId) val errorMsg = s"$UnknownError Can not create Dynamic Resource Doc in the backend. " (unboxFullOrFail(newInternalConnector, callContext, errorMsg, 400), callContext) } def updateJsonDynamicResourceDoc(bankId: Option[String], entity: JsonDynamicResourceDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicResourceDoc] = Future { - val updatedConnectorMethod = DynamicResourceDocProvider.provider.vend.update(bankId, entity: JsonDynamicResourceDoc) + val updatedByUserId = callContext.flatMap(_.user).map(_.userId) + val updatedConnectorMethod = DynamicResourceDocProvider.provider.vend.update(bankId, entity, updatedByUserId) val errorMsg = s"$UnknownError Can not update Dynamic Resource Doc in the backend. " (unboxFullOrFail(updatedConnectorMethod, callContext, errorMsg, 400), callContext) } diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala index 386447110c..710d187668 100644 --- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala +++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala @@ -1,6 +1,7 @@ package code.dynamicResourceDoc import org.json4s._ +import code.api.util.APIUtil import code.util.UUIDString import com.openbankproject.commons.util.json import net.liftweb.mapper._ @@ -8,7 +9,7 @@ import org.apache.commons.lang3.StringUtils import scala.collection.immutable.List -class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK { +class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK with CreatedUpdated { override def getSingleton = DynamicResourceDoc @@ -18,13 +19,19 @@ class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK { object RequestVerb extends MappedString(this, 255) object RequestUrl extends MappedString(this, 255) object Summary extends MappedString(this, 255) - object Description extends MappedString(this, 255) + object Description extends MappedString(this, 255) object ExampleRequestBody extends MappedString(this, 255) object SuccessResponseBody extends MappedString(this, 255) - object ErrorResponseBodies extends MappedString(this, 255) + object ErrorResponseBodies extends MappedString(this, 255) object Tags extends MappedString(this, 255) object Roles extends MappedString(this, 255) object MethodBody extends MappedText(this) + // Provenance: who created / last updated this runtime-compiled endpoint, and a SHA-256 of the + // (decoded) method body so tampering / drift is detectable. Set server-side from the CallContext + // user — never from the request body. createdAt / updatedAt come from the CreatedUpdated trait. + object CreatedByUserId extends MappedString(this, 255) + object UpdatedByUserId extends MappedString(this, 255) + object MethodBodyHash extends MappedString(this, 64) } @@ -44,7 +51,12 @@ object DynamicResourceDoc extends DynamicResourceDoc with LongKeyedMetaMapper[Dy successResponseBody = Option(dynamicResourceDoc.SuccessResponseBody.get).filter(StringUtils.isNotBlank).map(json.parse), errorResponseBodies = dynamicResourceDoc.ErrorResponseBodies.get, tags = dynamicResourceDoc.Tags.get, - roles = dynamicResourceDoc.Roles.get + roles = dynamicResourceDoc.Roles.get, + createdByUserId = Option(dynamicResourceDoc.CreatedByUserId.get).filter(StringUtils.isNotBlank), + updatedByUserId = Option(dynamicResourceDoc.UpdatedByUserId.get).filter(StringUtils.isNotBlank), + methodBodyHash = Option(dynamicResourceDoc.MethodBodyHash.get).filter(StringUtils.isNotBlank), + createdAt = Option(dynamicResourceDoc.createdAt.get).map(APIUtil.formatDate), + updatedAt = Option(dynamicResourceDoc.updatedAt.get).map(APIUtil.formatDate) ) } diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala index 9ec0927ae6..ebe33af226 100644 --- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala +++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala @@ -33,7 +33,15 @@ case class JsonDynamicResourceDoc( successResponseBody: Option[JValue], errorResponseBodies: String, tags: String, - roles: String + roles: String, + // Read-only provenance, populated server-side on the way out. Any value a caller supplies for + // these on create/update is ignored (see MappedDynamicResourceDocProvider) — provenance is taken + // from the authenticated CallContext user and computed hash, never trusted from the request body. + createdByUserId: Option[String] = None, + updatedByUserId: Option[String] = None, + methodBodyHash: Option[String] = None, + createdAt: Option[String] = None, + updatedAt: Option[String] = None ) extends JsonFieldReName { def decodedMethodBody: String = URLDecoder.decode(methodBody, "UTF-8") } @@ -42,13 +50,13 @@ trait DynamicResourceDocProvider { def getById(bankId: Option[String], dynamicResourceDocId: String): Box[JsonDynamicResourceDoc] def getByVerbAndUrl(bankId: Option[String], requestVerb: String, requestUrl: String): Box[JsonDynamicResourceDoc] - + def getAll(bankId: Option[String]): List[JsonDynamicResourceDoc] = getAllAndConvert(bankId, identity) def getAllAndConvert[T: Manifest](bankId: Option[String], transform: JsonDynamicResourceDoc => T): List[T] - def create(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc] - def update(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc] + def create(bankId: Option[String], entity: JsonDynamicResourceDoc, createdByUserId: Option[String]): Box[JsonDynamicResourceDoc] + def update(bankId: Option[String], entity: JsonDynamicResourceDoc, updatedByUserId: Option[String]): Box[JsonDynamicResourceDoc] def deleteById(bankId: Option[String], dynamicResourceDocId: String): Box[Boolean] } diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala index e2b86c544e..47be5d0442 100644 --- a/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala +++ b/obp-api/src/main/scala/code/dynamicResourceDoc/MappedDynamicResourceDocProvider.scala @@ -63,7 +63,7 @@ object MappedDynamicResourceDocProvider extends DynamicResourceDocProvider { } } - override def create(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc]= + override def create(bankId: Option[String], entity: JsonDynamicResourceDoc, createdByUserId: Option[String]): Box[JsonDynamicResourceDoc]= tryo { val requestBody = entity.exampleRequestBody.map(json.compactRender(_)).orNull val responseBody = entity.successResponseBody.map(json.compactRender(_)).orNull @@ -82,11 +82,14 @@ object MappedDynamicResourceDocProvider extends DynamicResourceDocProvider { .Tags(entity.tags) .Roles(entity.roles) .MethodBody(entity.methodBody) + // provenance is set here from the authenticated user + computed hash, not from `entity` + .CreatedByUserId(createdByUserId.getOrElse(null)) + .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody)) .saveMe() }.map(DynamicResourceDoc.getJsonDynamicResourceDoc) - override def update(bankId: Option[String], entity: JsonDynamicResourceDoc): Box[JsonDynamicResourceDoc] = { + override def update(bankId: Option[String], entity: JsonDynamicResourceDoc, updatedByUserId: Option[String]): Box[JsonDynamicResourceDoc] = { DynamicResourceDoc.find(By(DynamicResourceDoc.DynamicResourceDocId, entity.dynamicResourceDocId.getOrElse(""))) match { case Full(v) => tryo { @@ -104,6 +107,9 @@ object MappedDynamicResourceDocProvider extends DynamicResourceDocProvider { .Tags(entity.tags) .Roles(entity.roles) .MethodBody(entity.methodBody) + // CreatedByUserId is left untouched; record who last changed the code + refresh the hash + .UpdatedByUserId(updatedByUserId.getOrElse(null)) + .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody)) .saveMe() }.map(DynamicResourceDoc.getJsonDynamicResourceDoc) case _ => Empty diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala index f47fa3133f..c9808c5c35 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala @@ -385,4 +385,51 @@ class DynamicResourceDocTest extends V400ServerSetup { } } + feature("Provenance is captured on runtime-compiled dynamic resource docs") { + + scenario("Create records created_by_user_id, a method_body hash and created_at; update records updater and refreshes the hash", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canUpdateDynamicResourceDoc.toString) + + When("We create a dynamic resource doc, claiming a forged created_by_user_id in the request body") + val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + val posted = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy( + dynamicResourceDocId = None, + bankId = None, + partialFunctionName = "provenanceTest", + requestUrl = "/provenance_test_user/MY_USER_ID", + createdByUserId = Some("forged-user-id"), // must be ignored + methodBodyHash = Some("forged-hash") // must be ignored + ) + val createResp = makePostRequest(createReq, write(posted)) + createResp.code should equal(201) + val created = createResp.body.extract[JsonDynamicResourceDoc] + + Then("created_by_user_id is the authenticated user, not the forged value") + created.createdByUserId should be(Some(resourceUser1.userId)) + created.updatedByUserId should (be(None) or be(Some(""))) + + And("method_body_hash is the server-computed SHA-256 of the decoded method body, not the forged value") + val expectedHash = code.api.util.APIUtil.sha256Hex(posted.decodedMethodBody) + created.methodBodyHash should be(Some(expectedHash)) + created.createdAt.isDefined should be(true) + + When("We update the doc with a changed method body") + val changedMethodBody = URLEncoder.encode( + URLDecoder.decode(posted.methodBody, "UTF-8") + "\n // a change\n", "UTF-8") + val updateReq = (v4_0_0_Request / "management" / "dynamic-resource-docs" / created.dynamicResourceDocId.getOrElse("")).PUT <@ (user1) + val updateResp = makePutRequest(updateReq, + write(posted.copy(dynamicResourceDocId = created.dynamicResourceDocId, methodBody = changedMethodBody))) + updateResp.code should equal(200) + val updated = updateResp.body.extract[JsonDynamicResourceDoc] + + Then("created_by_user_id is preserved, updated_by_user_id is recorded, and the hash reflects the new body") + updated.createdByUserId should be(Some(resourceUser1.userId)) + updated.updatedByUserId should be(Some(resourceUser1.userId)) + updated.methodBodyHash should be(Some(code.api.util.APIUtil.sha256Hex( + URLDecoder.decode(changedMethodBody, "UTF-8")))) + updated.methodBodyHash shouldNot be(created.methodBodyHash) + } + } + } From f98888b5e4b2cf01a9ed413e8e6f56ab31d7e0e4 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 25 Aug 2026 10:59:01 +0200 Subject: [PATCH 5/5] For Dynamic Message Doc Dynamic Resource Doc and Connector Method : Added CreatedUpdated and methodbody hash in GET v7 endpoints and reverted the v4 endpoints so they remain stable. --- .../main/scala/code/api/util/NewStyle.scala | 14 +- .../scala/code/api/v7_0_0/Http4s700.scala | 184 ++++++++++++++++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 50 +++++ .../bankconnectors/InternalConnector.scala | 3 +- .../connectormethod/ConnectorMethod.scala | 18 +- .../ConnectorMethodProvider.scala | 4 +- .../MappedConnectorMethodProvider.scala | 28 +-- .../dynamicMessageDoc/DynamicMessageDoc.scala | 29 +-- .../DynamicMessageDocProvider.scala | 4 +- .../MappedDynamicMessageDocProvider.scala | 10 +- .../DynamicResourceDoc.scala | 8 +- .../DynamicResourceDocProvider.scala | 10 +- .../code/api/v4_0_0/ConnectorMethodTest.scala | 9 +- .../api/v4_0_0/DynamicMessageDocTest.scala | 9 +- .../api/v4_0_0/DynamicResourceDocTest.scala | 40 ++-- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 90 +++++++++ 16 files changed, 436 insertions(+), 74 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 88c4e98806..8145f828b9 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4272,14 +4272,17 @@ object NewStyle extends MdcLoggable{ def createJsonConnectorMethod(connectorMethod: JsonConnectorMethod, callContext: Option[CallContext]): OBPReturnType[JsonConnectorMethod] = Future { - val newInternalConnector = ConnectorMethodProvider.provider.vend.create(connectorMethod) + // provenance is taken from the authenticated CallContext user, never from the request body + val createdByUserId = callContext.flatMap(_.user).map(_.userId) + val newInternalConnector = ConnectorMethodProvider.provider.vend.create(connectorMethod, createdByUserId) val errorMsg = s"$UnknownError Can not create Connector Method in the backend. " (unboxFullOrFail(newInternalConnector, callContext, errorMsg, 400), callContext) } def updateJsonConnectorMethod(connectorMethodId: String, connectorMethodBody: String, programmingLang: String, callContext: Option[CallContext]): OBPReturnType[JsonConnectorMethod] = Future { - val updatedConnectorMethod = ConnectorMethodProvider.provider.vend.update(connectorMethodId, connectorMethodBody, programmingLang) + val updatedByUserId = callContext.flatMap(_.user).map(_.userId) + val updatedConnectorMethod = ConnectorMethodProvider.provider.vend.update(connectorMethodId, connectorMethodBody, programmingLang, updatedByUserId) val errorMsg = s"$UnknownError Can not update Connector Method in the backend. " (unboxFullOrFail(updatedConnectorMethod, callContext, errorMsg, 400), callContext) } @@ -4357,14 +4360,17 @@ object NewStyle extends MdcLoggable{ def createJsonDynamicMessageDoc(bankId: Option[String], dynamicMessageDoc: JsonDynamicMessageDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicMessageDoc] = Future { - val newInternalConnector = DynamicMessageDocProvider.provider.vend.create(bankId, dynamicMessageDoc) + // provenance is taken from the authenticated CallContext user, never from the request body + val createdByUserId = callContext.flatMap(_.user).map(_.userId) + val newInternalConnector = DynamicMessageDocProvider.provider.vend.create(bankId, dynamicMessageDoc, createdByUserId) val errorMsg = s"$UnknownError Can not create Dynamic Message Doc in the backend. " (unboxFullOrFail(newInternalConnector, callContext, errorMsg, 400), callContext) } def updateJsonDynamicMessageDoc(bankId: Option[String], entity: JsonDynamicMessageDoc, callContext: Option[CallContext]): OBPReturnType[JsonDynamicMessageDoc] = Future { - val updatedConnectorMethod = DynamicMessageDocProvider.provider.vend.update(bankId: Option[String], entity: JsonDynamicMessageDoc) + val updatedByUserId = callContext.flatMap(_.user).map(_.userId) + val updatedConnectorMethod = DynamicMessageDocProvider.provider.vend.update(bankId, entity, updatedByUserId) val errorMsg = s"$UnknownError Can not update Dynamic Message Doc in the backend. " (unboxFullOrFail(updatedConnectorMethod, callContext, errorMsg, 400), callContext) } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index cbcbe948bb..889441ddd2 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -4691,6 +4691,190 @@ object Http4s700 { ) } + // ═══════════════════════════════════════════════════════════════════════════ + // Dynamic-code provenance (v7.0.0, read-only) + // ───────────────────────────────────────────────────────────────────────── + // GET-only endpoints that expose the provenance captured on the v4.0.0 create/update + // endpoints (created_by_user_id, updated_by_user_id, method_body_hash, created_at, + // updated_at) for the three runtime-compiled-code types. The v4 create/update/get shapes are + // frozen (STABLE); these v7 reads wrap the unchanged v4 resource JSON with a `provenance` + // object. Create/update/delete stay on v4. Roles mirror the v4 GET roles. + // ═══════════════════════════════════════════════════════════════════════════ + + val getDynamicResourceDocsProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-resource-docs" => + EndpointHelpers.withUser(req) { (_, cc) => + Future(code.dynamicResourceDoc.DynamicResourceDoc.findAll()) + .map(rows => JSONFactory700.DynamicResourceDocsProvenanceJsonV700( + rows.map(JSONFactory700.createDynamicResourceDocProvenanceJsonV700))) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getDynamicResourceDocsProvenance), + "GET", + "/management/dynamic-resource-docs", + "Get Dynamic Resource Docs (with provenance)", + s"""Returns all Dynamic Resource Docs, each wrapped with a `provenance` object recording who created / last updated the runtime-compiled code and a SHA-256 of its method body. + | + |This is the v7.0.0 read view of the v4.0.0 Dynamic Resource Docs; create / update / delete remain on v4.0.0. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.DynamicResourceDocsProvenanceJsonV700(Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagDynamicResourceDoc :: Nil, + Some(List(ApiRole.canGetAllDynamicResourceDocs)), + http4sPartialFunction = Some(getDynamicResourceDocsProvenance) + ) + + val getDynamicResourceDocProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.withUser(req) { (_, cc) => + Future(code.dynamicResourceDoc.DynamicResourceDoc.find( + By(code.dynamicResourceDoc.DynamicResourceDoc.DynamicResourceDocId, dynamicResourceDocId))) + .map(box => unboxFullOrFail(box, Some(cc), s"$DynamicResourceDocNotFound Current DYNAMIC_RESOURCE_DOC_ID($dynamicResourceDocId)", 404)) + .map(JSONFactory700.createDynamicResourceDocProvenanceJsonV700) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getDynamicResourceDocProvenance), + "GET", + "/management/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Get Dynamic Resource Doc (with provenance)", + s"""Returns the Dynamic Resource Doc specified by DYNAMIC_RESOURCE_DOC_ID, wrapped with a `provenance` object (created_by_user_id, updated_by_user_id, method_body_hash, created_at, updated_at). + | + |This is the v7.0.0 read view of the v4.0.0 Dynamic Resource Doc; create / update / delete remain on v4.0.0. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.DynamicResourceDocProvenanceJsonV700( + jsonDynamicResourceDoc, + JSONFactory700.ProvenanceJsonV700(Some(code.api.util.ExampleValue.userIdExample.value), None, Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), Some(APIUtil.DateWithMsExampleString), Some(APIUtil.DateWithMsExampleString)) + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicResourceDocNotFound, UnknownError), + apiTagDynamicResourceDoc :: Nil, + Some(List(ApiRole.canGetDynamicResourceDoc)), + http4sPartialFunction = Some(getDynamicResourceDocProvenance) + ) + + val getConnectorMethodsProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector-methods" => + EndpointHelpers.withUser(req) { (_, cc) => + Future(code.connectormethod.ConnectorMethod.findAll()) + .map(rows => JSONFactory700.ConnectorMethodsProvenanceJsonV700( + rows.map(JSONFactory700.createConnectorMethodProvenanceJsonV700))) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getConnectorMethodsProvenance), + "GET", + "/management/connector-methods", + "Get Connector Methods (with provenance)", + s"""Returns all Connector Methods, each wrapped with a `provenance` object recording who created / last updated the runtime-compiled code and a SHA-256 of its method body. + | + |This is the v7.0.0 read view of the v4.0.0 Connector Methods; create / update remain on v4.0.0. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.ConnectorMethodsProvenanceJsonV700(Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConnectorMethod :: Nil, + Some(List(ApiRole.canGetAllConnectorMethods)), + http4sPartialFunction = Some(getConnectorMethodsProvenance) + ) + + val getConnectorMethodProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector-methods" / connectorMethodId => + EndpointHelpers.withUser(req) { (_, cc) => + Future(code.connectormethod.ConnectorMethod.find( + By(code.connectormethod.ConnectorMethod.ConnectorMethodId, connectorMethodId))) + .map(box => unboxFullOrFail(box, Some(cc), s"$ConnectorMethodNotFound Current CONNECTOR_METHOD_ID($connectorMethodId)", 404)) + .map(JSONFactory700.createConnectorMethodProvenanceJsonV700) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getConnectorMethodProvenance), + "GET", + "/management/connector-methods/CONNECTOR_METHOD_ID", + "Get Connector Method (with provenance)", + s"""Returns the Connector Method specified by CONNECTOR_METHOD_ID, wrapped with a `provenance` object (created_by_user_id, updated_by_user_id, method_body_hash, created_at, updated_at). + | + |This is the v7.0.0 read view of the v4.0.0 Connector Method; create / update remain on v4.0.0. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.ConnectorMethodProvenanceJsonV700( + jsonScalaConnectorMethod, + JSONFactory700.ProvenanceJsonV700(Some(code.api.util.ExampleValue.userIdExample.value), None, Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), Some(APIUtil.DateWithMsExampleString), Some(APIUtil.DateWithMsExampleString)) + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConnectorMethodNotFound, UnknownError), + apiTagConnectorMethod :: Nil, + Some(List(ApiRole.canGetConnectorMethod)), + http4sPartialFunction = Some(getConnectorMethodProvenance) + ) + + val getDynamicMessageDocsProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-message-docs" => + EndpointHelpers.withUser(req) { (_, cc) => + Future(code.dynamicMessageDoc.DynamicMessageDoc.findAll()) + .map(rows => JSONFactory700.DynamicMessageDocsProvenanceJsonV700( + rows.map(JSONFactory700.createDynamicMessageDocProvenanceJsonV700))) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getDynamicMessageDocsProvenance), + "GET", + "/management/dynamic-message-docs", + "Get Dynamic Message Docs (with provenance)", + s"""Returns all Dynamic Message Docs, each wrapped with a `provenance` object recording who created / last updated the runtime-compiled code and a SHA-256 of its method body. + | + |This is the v7.0.0 read view of the v4.0.0 Dynamic Message Docs; create / update / delete remain on v4.0.0. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.DynamicMessageDocsProvenanceJsonV700(Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagDynamicMessageDoc :: Nil, + Some(List(ApiRole.canGetAllDynamicMessageDocs)), + http4sPartialFunction = Some(getDynamicMessageDocsProvenance) + ) + + val getDynamicMessageDocProvenance: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.withUser(req) { (_, cc) => + Future(code.dynamicMessageDoc.DynamicMessageDoc.find( + By(code.dynamicMessageDoc.DynamicMessageDoc.DynamicMessageDocId, dynamicMessageDocId))) + .map(box => unboxFullOrFail(box, Some(cc), s"$DynamicMessageDocNotFound Current DYNAMIC_MESSAGE_DOC_ID($dynamicMessageDocId)", 404)) + .map(JSONFactory700.createDynamicMessageDocProvenanceJsonV700) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getDynamicMessageDocProvenance), + "GET", + "/management/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Get Dynamic Message Doc (with provenance)", + s"""Returns the Dynamic Message Doc specified by DYNAMIC_MESSAGE_DOC_ID, wrapped with a `provenance` object (created_by_user_id, updated_by_user_id, method_body_hash, created_at, updated_at). + | + |This is the v7.0.0 read view of the v4.0.0 Dynamic Message Doc; create / update / delete remain on v4.0.0. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + JSONFactory700.DynamicMessageDocProvenanceJsonV700( + jsonDynamicMessageDoc, + JSONFactory700.ProvenanceJsonV700(Some(code.api.util.ExampleValue.userIdExample.value), None, Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), Some(APIUtil.DateWithMsExampleString), Some(APIUtil.DateWithMsExampleString)) + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, DynamicMessageDocNotFound, UnknownError), + apiTagDynamicMessageDoc :: Nil, + Some(List(ApiRole.canGetDynamicMessageDoc)), + http4sPartialFunction = Some(getDynamicMessageDocProvenance) + ) + // All routes combined (without middleware - for direct use). // // Routes are sorted automatically by URL template specificity (segment count, diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 79f4017501..44ced1f6ea 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -11,6 +11,10 @@ import code.metrics.{MappedMetric, MetricArchive, MetricsArchiveRun, MetricsProp import code.util.Helper.MdcLoggable import code.views.Views import code.api.v3_1_0.{AccountAttributeResponseJson, JSONFactory310} +import code.dynamicResourceDoc.{DynamicResourceDoc, JsonDynamicResourceDoc} +import code.connectormethod.{ConnectorMethod, JsonConnectorMethod} +import code.dynamicMessageDoc.{DynamicMessageDoc, JsonDynamicMessageDoc} +import org.apache.commons.lang3.StringUtils import com.openbankproject.commons.model.{AccountAttribute, AccountId, AccountRoutingJsonV121, AmountOfMoneyJsonV121, BankAccount, BankId, BankIdAccountId, CoreAccount, TransactionRequest, TransactionRequestCommonBodyJSON, User} import com.openbankproject.commons.util.ApiVersion import java.util.Date @@ -21,6 +25,52 @@ import scala.concurrent.{ExecutionContext, Future} object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { + // ─── Provenance for runtime-compiled dynamic code (v7.0.0 read-only exposure) ─── + // The v4.0.0 create/update endpoints capture who created / last updated a piece of runtime + // code and a SHA-256 of its (decoded) method body into DB columns, but the v4 response shape + // is frozen (STABLE) and does not carry them. These v7 GET endpoints expose that provenance, + // wrapping the unchanged v4 resource JSON alongside a `provenance` object. + case class ProvenanceJsonV700( + created_by_user_id: Option[String], + updated_by_user_id: Option[String], + method_body_hash: Option[String], + created_at: Option[String], + updated_at: Option[String] + ) + case class DynamicResourceDocProvenanceJsonV700(dynamic_resource_doc: JsonDynamicResourceDoc, provenance: ProvenanceJsonV700) + case class DynamicResourceDocsProvenanceJsonV700(dynamic_resource_docs: List[DynamicResourceDocProvenanceJsonV700]) + case class ConnectorMethodProvenanceJsonV700(connector_method: JsonConnectorMethod, provenance: ProvenanceJsonV700) + case class ConnectorMethodsProvenanceJsonV700(connector_methods: List[ConnectorMethodProvenanceJsonV700]) + case class DynamicMessageDocProvenanceJsonV700(dynamic_message_doc: JsonDynamicMessageDoc, provenance: ProvenanceJsonV700) + case class DynamicMessageDocsProvenanceJsonV700(dynamic_message_docs: List[DynamicMessageDocProvenanceJsonV700]) + + private def blankToNone(s: String): Option[String] = Option(s).filter(StringUtils.isNotBlank) + private def formatDateOpt(d: Date): Option[String] = Option(d).map(APIUtil.formatDate) + + def createDynamicResourceDocProvenanceJsonV700(entity: DynamicResourceDoc): DynamicResourceDocProvenanceJsonV700 = + DynamicResourceDocProvenanceJsonV700( + DynamicResourceDoc.getJsonDynamicResourceDoc(entity), + ProvenanceJsonV700( + blankToNone(entity.CreatedByUserId.get), blankToNone(entity.UpdatedByUserId.get), + blankToNone(entity.MethodBodyHash.get), formatDateOpt(entity.createdAt.get), formatDateOpt(entity.updatedAt.get)) + ) + + def createConnectorMethodProvenanceJsonV700(entity: ConnectorMethod): ConnectorMethodProvenanceJsonV700 = + ConnectorMethodProvenanceJsonV700( + ConnectorMethod.getJsonConnectorMethod(entity), + ProvenanceJsonV700( + blankToNone(entity.CreatedByUserId.get), blankToNone(entity.UpdatedByUserId.get), + blankToNone(entity.MethodBodyHash.get), formatDateOpt(entity.createdAt.get), formatDateOpt(entity.updatedAt.get)) + ) + + def createDynamicMessageDocProvenanceJsonV700(entity: DynamicMessageDoc): DynamicMessageDocProvenanceJsonV700 = + DynamicMessageDocProvenanceJsonV700( + DynamicMessageDoc.getJsonDynamicMessageDoc(entity), + ProvenanceJsonV700( + blankToNone(entity.CreatedByUserId.get), blankToNone(entity.UpdatedByUserId.get), + blankToNone(entity.MethodBodyHash.get), formatDateOpt(entity.createdAt.get), formatDateOpt(entity.updatedAt.get)) + ) + case class ErrorMessageEntryJsonV700(code: String, name: String, message: String) // Cached for server lifetime: ErrorMessages is a static catalog of `val X = "OBP-NNNNN: ..."` diff --git a/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala b/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala index 758f5b10ba..051c515af1 100644 --- a/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/InternalConnector.scala @@ -240,7 +240,8 @@ object InternalConnector { private def callableMethods: Map[String, MethodSymbol] = { val dynamicMethods: Map[String, MethodSymbol] = ConnectorMethodProvider.provider.vend.getAll().map { - case JsonConnectorMethod(_, methodName, _, _) => + case v: JsonConnectorMethod => + val methodName = v.methodName methodName -> Box(methodNameToSymbols.get(methodName)).openOrThrowException(s"method name $methodName does not exist in the Connector") }.toMap diff --git a/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala b/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala index ad456a9b5d..5108c09971 100644 --- a/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala +++ b/obp-api/src/main/scala/code/connectormethod/ConnectorMethod.scala @@ -3,7 +3,7 @@ package code.connectormethod import code.util.UUIDString import net.liftweb.mapper._ -class ConnectorMethod extends LongKeyedMapper[ConnectorMethod] with IdPK { +class ConnectorMethod extends LongKeyedMapper[ConnectorMethod] with IdPK with CreatedUpdated { override def getSingleton = ConnectorMethod @@ -13,10 +13,26 @@ class ConnectorMethod extends LongKeyedMapper[ConnectorMethod] with IdPK { object MethodBody extends MappedText(this) object Lang extends MappedString(this, 50) + // Provenance for this runtime-compiled connector method: who created / last updated it and a + // SHA-256 of the (decoded) method body. Set server-side from the CallContext user, never the + // request body. createdAt / updatedAt come from the CreatedUpdated trait. + object CreatedByUserId extends MappedString(this, 255) + object UpdatedByUserId extends MappedString(this, 255) + object MethodBodyHash extends MappedString(this, 64) } object ConnectorMethod extends ConnectorMethod with LongKeyedMetaMapper[ConnectorMethod] { override def dbIndexes: List[BaseIndex[ConnectorMethod]] = UniqueIndex(ConnectorMethodId) :: UniqueIndex(MethodName) :: super.dbIndexes + + // Note: provenance (CreatedByUserId / UpdatedByUserId / MethodBodyHash / createdAt / updatedAt) is + // captured in the columns above but intentionally NOT surfaced in this v4.0.0 (STABLE) JSON — the + // v4 response shape is frozen. It will be exposed via a new (v7) endpoint version. + def getJsonConnectorMethod(it: ConnectorMethod): JsonConnectorMethod = JsonConnectorMethod( + connectorMethodId = Some(it.ConnectorMethodId.get), + methodName = it.MethodName.get, + methodBody = it.MethodBody.get, + programmingLang = Option(it.Lang.get).getOrElse("Scala") + ) } diff --git a/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala b/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala index fb11b6cc82..ec020ffa36 100644 --- a/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala +++ b/obp-api/src/main/scala/code/connectormethod/ConnectorMethodProvider.scala @@ -29,8 +29,8 @@ trait ConnectorMethodProvider { def getAll(): List[JsonConnectorMethod] - def create(entity: JsonConnectorMethod): Box[JsonConnectorMethod] - def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String): Box[JsonConnectorMethod] + def create(entity: JsonConnectorMethod, createdByUserId: Option[String]): Box[JsonConnectorMethod] + def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String, updatedByUserId: Option[String]): Box[JsonConnectorMethod] def deleteById(connectorMethodId: String): Box[Boolean] } diff --git a/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala b/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala index 426f9b047a..23a74ba0e6 100644 --- a/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala +++ b/obp-api/src/main/scala/code/connectormethod/MappedConnectorMethodProvider.scala @@ -17,17 +17,15 @@ object MappedConnectorMethodProvider extends ConnectorMethodProvider { if(Props.testMode) 0 else APIUtil.getPropsValue(s"connectorMethod.cache.ttl.seconds", "40").toInt } - private def getLang(connectorMethod: ConnectorMethod): String = Option(connectorMethod.Lang.get).getOrElse("Scala") - override def getById(connectorMethodId: String): Box[JsonConnectorMethod] = ConnectorMethod .find(By(ConnectorMethod.ConnectorMethodId, connectorMethodId)) - .map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it))) + .map(ConnectorMethod.getJsonConnectorMethod) override def getByMethodNameWithoutCache(methodName: String): Box[JsonConnectorMethod] = { ConnectorMethod.find(By(ConnectorMethod.MethodName, methodName)) - .map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it))) + .map(ConnectorMethod.getJsonConnectorMethod) } - + override def getByMethodNameWithCache(methodName: String): Box[JsonConnectorMethod] = { var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString) CacheKeyFromArguments.buildCacheKey { @@ -40,27 +38,35 @@ object MappedConnectorMethodProvider extends ConnectorMethodProvider { CacheKeyFromArguments.buildCacheKey { Caching.memoizeSyncWithProvider (Some(cacheKey.toString())) (getConnectorMethodTTL.second) { ConnectorMethod.findAll() - .map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it))) + .map(ConnectorMethod.getJsonConnectorMethod) }} } - override def create(entity: JsonConnectorMethod): Box[JsonConnectorMethod]= + override def create(entity: JsonConnectorMethod, createdByUserId: Option[String]): Box[JsonConnectorMethod]= tryo { ConnectorMethod.create .ConnectorMethodId(APIUtil.generateUUID()) .MethodName(entity.methodName) .MethodBody(entity.methodBody) .Lang(entity.programmingLang) + // provenance is set here from the authenticated user + computed hash, not from `entity` + .CreatedByUserId(createdByUserId.getOrElse(null)) + .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody)) .saveMe() - }.map(it => JsonConnectorMethod(Some(it.ConnectorMethodId.get), it.MethodName.get, it.MethodBody.get, getLang(it))) + }.map(ConnectorMethod.getJsonConnectorMethod) - override def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String): Box[JsonConnectorMethod] = { + override def update(connectorMethodId: String, connectorMethodBody: String, programmingLang: String, updatedByUserId: Option[String]): Box[JsonConnectorMethod] = { ConnectorMethod.find(By(ConnectorMethod.ConnectorMethodId, connectorMethodId)) match { case Full(v) => tryo { - v.MethodBody(connectorMethodBody).Lang(programmingLang).saveMe() - }.map(it => JsonConnectorMethod(Some(connectorMethodId), it.MethodName.get, it.MethodBody.get, getLang(it))) + v.MethodBody(connectorMethodBody) + .Lang(programmingLang) + // CreatedByUserId is left untouched; record who last changed the code + refresh the hash + .UpdatedByUserId(updatedByUserId.getOrElse(null)) + .MethodBodyHash(APIUtil.sha256Hex(java.net.URLDecoder.decode(connectorMethodBody, "UTF-8"))) + .saveMe() + }.map(ConnectorMethod.getJsonConnectorMethod) case _ => Empty } } diff --git a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala index 4aac12a801..f22e742c92 100644 --- a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala +++ b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDoc.scala @@ -4,26 +4,33 @@ import org.json4s._ import code.util.UUIDString import com.openbankproject.commons.util.json import net.liftweb.mapper._ + import scala.collection.immutable.List -class DynamicMessageDoc extends LongKeyedMapper[DynamicMessageDoc] with IdPK { +class DynamicMessageDoc extends LongKeyedMapper[DynamicMessageDoc] with IdPK with CreatedUpdated { override def getSingleton = DynamicMessageDoc object BankId extends MappedString(this, 255) - object DynamicMessageDocId extends UUIDString(this) - object Process extends MappedString(this, 255) - object MessageFormat extends MappedString(this, 255) - object Description extends MappedString(this, 255) - object OutboundTopic extends MappedString(this, 255) - object InboundTopic extends MappedString(this, 255) - object ExampleOutboundMessage extends MappedText(this) - object ExampleInboundMessage extends MappedText(this) + object DynamicMessageDocId extends UUIDString(this) + object Process extends MappedString(this, 255) + object MessageFormat extends MappedString(this, 255) + object Description extends MappedString(this, 255) + object OutboundTopic extends MappedString(this, 255) + object InboundTopic extends MappedString(this, 255) + object ExampleOutboundMessage extends MappedText(this) + object ExampleInboundMessage extends MappedText(this) object OutboundAvroSchema extends MappedText(this) - object InboundAvroSchema extends MappedText(this) - object AdapterImplementation extends MappedString(this, 255) + object InboundAvroSchema extends MappedText(this) + object AdapterImplementation extends MappedString(this, 255) object MethodBody extends MappedText(this) object Lang extends MappedString(this, 50) + // Provenance for this runtime-compiled connector function: who created / last updated it and a + // SHA-256 of the (decoded) method body. Set server-side from the CallContext user, never the + // request body. createdAt / updatedAt come from the CreatedUpdated trait. + object CreatedByUserId extends MappedString(this, 255) + object UpdatedByUserId extends MappedString(this, 255) + object MethodBodyHash extends MappedString(this, 64) } diff --git a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala index c47a94631a..d5a0edf069 100644 --- a/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala +++ b/obp-api/src/main/scala/code/dynamicMessageDoc/DynamicMessageDocProvider.scala @@ -41,8 +41,8 @@ trait DynamicMessageDocProvider { def getByProcess(bankId: Option[String], process: String): Box[JsonDynamicMessageDoc] def getAll(bankId: Option[String]): List[JsonDynamicMessageDoc] - def create(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc] - def update(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc] + def create(bankId: Option[String], entity: JsonDynamicMessageDoc, createdByUserId: Option[String]): Box[JsonDynamicMessageDoc] + def update(bankId: Option[String], entity: JsonDynamicMessageDoc, updatedByUserId: Option[String]): Box[JsonDynamicMessageDoc] def deleteById(bankId: Option[String], dynamicMessageDocId: String): Box[Boolean] } \ No newline at end of file diff --git a/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala b/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala index 81240419f1..946e89edf3 100644 --- a/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala +++ b/obp-api/src/main/scala/code/dynamicMessageDoc/MappedDynamicMessageDocProvider.scala @@ -53,7 +53,7 @@ object MappedDynamicMessageDocProvider extends DynamicMessageDocProvider { }} } - override def create(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc]= { + override def create(bankId: Option[String], entity: JsonDynamicMessageDoc, createdByUserId: Option[String]): Box[JsonDynamicMessageDoc]= { tryo { DynamicMessageDoc.create .BankId(bankId.getOrElse(null)) @@ -70,12 +70,15 @@ object MappedDynamicMessageDocProvider extends DynamicMessageDocProvider { .AdapterImplementation(entity.adapterImplementation) .MethodBody(entity.methodBody) .Lang(entity.programmingLang) + // provenance is set here from the authenticated user + computed hash, not from `entity` + .CreatedByUserId(createdByUserId.getOrElse(null)) + .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody)) .saveMe() }.map(DynamicMessageDoc.getJsonDynamicMessageDoc) } - override def update(bankId: Option[String], entity: JsonDynamicMessageDoc): Box[JsonDynamicMessageDoc] = { + override def update(bankId: Option[String], entity: JsonDynamicMessageDoc, updatedByUserId: Option[String]): Box[JsonDynamicMessageDoc] = { val dynamicMessageDocBox = if(bankId.isDefined){ DynamicMessageDoc.find( By(DynamicMessageDoc.DynamicMessageDocId, entity.dynamicMessageDocId.getOrElse("")), @@ -102,6 +105,9 @@ object MappedDynamicMessageDocProvider extends DynamicMessageDocProvider { .AdapterImplementation(entity.adapterImplementation) .MethodBody(entity.methodBody) .Lang(entity.programmingLang) + // CreatedByUserId is left untouched; record who last changed the code + refresh the hash + .UpdatedByUserId(updatedByUserId.getOrElse(null)) + .MethodBodyHash(APIUtil.sha256Hex(entity.decodedMethodBody)) .saveMe() }.map(DynamicMessageDoc.getJsonDynamicMessageDoc) case _ => Empty diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala index 710d187668..d11c21ae41 100644 --- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala +++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala @@ -1,7 +1,6 @@ package code.dynamicResourceDoc import org.json4s._ -import code.api.util.APIUtil import code.util.UUIDString import com.openbankproject.commons.util.json import net.liftweb.mapper._ @@ -51,12 +50,7 @@ object DynamicResourceDoc extends DynamicResourceDoc with LongKeyedMetaMapper[Dy successResponseBody = Option(dynamicResourceDoc.SuccessResponseBody.get).filter(StringUtils.isNotBlank).map(json.parse), errorResponseBodies = dynamicResourceDoc.ErrorResponseBodies.get, tags = dynamicResourceDoc.Tags.get, - roles = dynamicResourceDoc.Roles.get, - createdByUserId = Option(dynamicResourceDoc.CreatedByUserId.get).filter(StringUtils.isNotBlank), - updatedByUserId = Option(dynamicResourceDoc.UpdatedByUserId.get).filter(StringUtils.isNotBlank), - methodBodyHash = Option(dynamicResourceDoc.MethodBodyHash.get).filter(StringUtils.isNotBlank), - createdAt = Option(dynamicResourceDoc.createdAt.get).map(APIUtil.formatDate), - updatedAt = Option(dynamicResourceDoc.updatedAt.get).map(APIUtil.formatDate) + roles = dynamicResourceDoc.Roles.get ) } diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala index ebe33af226..0dc263d045 100644 --- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala +++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDocProvider.scala @@ -33,15 +33,7 @@ case class JsonDynamicResourceDoc( successResponseBody: Option[JValue], errorResponseBodies: String, tags: String, - roles: String, - // Read-only provenance, populated server-side on the way out. Any value a caller supplies for - // these on create/update is ignored (see MappedDynamicResourceDocProvider) — provenance is taken - // from the authenticated CallContext user and computed hash, never trusted from the request body. - createdByUserId: Option[String] = None, - updatedByUserId: Option[String] = None, - methodBodyHash: Option[String] = None, - createdAt: Option[String] = None, - updatedAt: Option[String] = None + roles: String ) extends JsonFieldReName { def decodedMethodBody: String = URLDecoder.decode(methodBody, "UTF-8") } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala index a2da8727af..4fad2e1353 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ConnectorMethodTest.scala @@ -99,6 +99,13 @@ class ConnectorMethodTest extends V400ServerSetup { connectorMethod.methodBody should be (postConnectorMethod.methodBody) connectorMethod.connectorMethodId shouldNot be (null) + Then("provenance is captured server-side into the stored row (not surfaced in the frozen v4 response)") + val storedConnectorMethod = code.connectormethod.ConnectorMethod + .find(net.liftweb.mapper.By(code.connectormethod.ConnectorMethod.ConnectorMethodId, connectorMethod.connectorMethodId.getOrElse(""))) + .openOrThrowException("stored connector method not found") + storedConnectorMethod.CreatedByUserId.get should be (resourceUser1.userId) + storedConnectorMethod.MethodBodyHash.get should be (code.api.util.APIUtil.sha256Hex(postConnectorMethod.decodedMethodBody)) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateMethodRouting.toString) val requestCreateMethodRouting = (v4_0_0_Request / "management" / "method_routings").POST <@(user1) @@ -279,7 +286,7 @@ class ConnectorMethodTest extends V400ServerSetup { |) |""".stripMargin val encodedMethodBody = URLEncoder.encode(methodBody, "UTF-8") - ConnectorMethodProvider.provider.vend.create(JsonConnectorMethod(Some("Hello_bank_id"), "getBank", encodedMethodBody)) + ConnectorMethodProvider.provider.vend.create(JsonConnectorMethod(Some("Hello_bank_id"), "getBank", encodedMethodBody), None) val connectorMethod = InternalConnector.instance Then("Call dynamic method") diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala index 2c53c8af95..8935de7b33 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicMessageDocTest.scala @@ -97,6 +97,13 @@ class DynamicMessageDocTest extends V400ServerSetup { dynamicMessageDoc.inboundAvroSchema should be (postDynamicMessageDoc.inboundAvroSchema) dynamicMessageDoc.adapterImplementation should be (postDynamicMessageDoc.adapterImplementation) + Then("provenance is captured server-side into the stored row (not surfaced in the frozen v4 response)") + val storedMessageDoc = code.dynamicMessageDoc.DynamicMessageDoc + .find(net.liftweb.mapper.By(code.dynamicMessageDoc.DynamicMessageDoc.DynamicMessageDocId, dynamicMessageDoc.dynamicMessageDocId.getOrElse(""))) + .openOrThrowException("stored dynamic message doc not found") + storedMessageDoc.CreatedByUserId.get should be (resourceUser1.userId) + storedMessageDoc.MethodBodyHash.get should be (code.api.util.APIUtil.sha256Hex(postDynamicMessageDoc.decodedMethodBody)) + Then(s"we test the $ApiEndpoint2") val requestGet = (v4_0_0_Request / "management" / "dynamic-message-docs" / {dynamicMessageDoc.dynamicMessageDocId.getOrElse("")}).GET <@ (user1) @@ -292,7 +299,7 @@ class DynamicMessageDocTest extends V400ServerSetup { ) When("We store the DynamicMessageDoc via the provider") - DynamicMessageDocProvider.provider.vend.create(None, doc).isDefined should equal(true) + DynamicMessageDocProvider.provider.vend.create(None, doc, None).isDefined should equal(true) Then("DynamicConnector.invoke compiles the stored methodBody and runs the connector method") val fut = DynamicConnector diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala index c9808c5c35..a482557a18 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicResourceDocTest.scala @@ -385,50 +385,46 @@ class DynamicResourceDocTest extends V400ServerSetup { } } + // Provenance is captured server-side into the DB columns but intentionally NOT surfaced in the + // v4.0.0 (STABLE) response JSON — the v4 shape is frozen, so we assert against the stored entity, + // not the response. (Exposure of these fields is planned for a new, v7, endpoint version.) feature("Provenance is captured on runtime-compiled dynamic resource docs") { - scenario("Create records created_by_user_id, a method_body hash and created_at; update records updater and refreshes the hash", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { + scenario("Create stores created_by_user_id + method_body hash; update records the updater and refreshes the hash", ApiEndpoint1, ApiEndpoint2, VersionOfApi) { Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canUpdateDynamicResourceDoc.toString) - When("We create a dynamic resource doc, claiming a forged created_by_user_id in the request body") + When("We create a dynamic resource doc") val createReq = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) val posted = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy( dynamicResourceDocId = None, bankId = None, partialFunctionName = "provenanceTest", - requestUrl = "/provenance_test_user/MY_USER_ID", - createdByUserId = Some("forged-user-id"), // must be ignored - methodBodyHash = Some("forged-hash") // must be ignored + requestUrl = "/provenance_test_user/MY_USER_ID" ) val createResp = makePostRequest(createReq, write(posted)) createResp.code should equal(201) - val created = createResp.body.extract[JsonDynamicResourceDoc] + val docId = (createResp.body \ "dynamic_resource_doc_id").values.toString - Then("created_by_user_id is the authenticated user, not the forged value") - created.createdByUserId should be(Some(resourceUser1.userId)) - created.updatedByUserId should (be(None) or be(Some(""))) - - And("method_body_hash is the server-computed SHA-256 of the decoded method body, not the forged value") - val expectedHash = code.api.util.APIUtil.sha256Hex(posted.decodedMethodBody) - created.methodBodyHash should be(Some(expectedHash)) - created.createdAt.isDefined should be(true) + Then("the stored row records the authenticated caller and the server-computed SHA-256 of the decoded body") + def storedRow = code.dynamicResourceDoc.DynamicResourceDoc + .find(net.liftweb.mapper.By(code.dynamicResourceDoc.DynamicResourceDoc.DynamicResourceDocId, docId)) + .openOrThrowException("stored dynamic resource doc not found") + storedRow.CreatedByUserId.get should be(resourceUser1.userId) + storedRow.MethodBodyHash.get should be(code.api.util.APIUtil.sha256Hex(posted.decodedMethodBody)) When("We update the doc with a changed method body") val changedMethodBody = URLEncoder.encode( URLDecoder.decode(posted.methodBody, "UTF-8") + "\n // a change\n", "UTF-8") - val updateReq = (v4_0_0_Request / "management" / "dynamic-resource-docs" / created.dynamicResourceDocId.getOrElse("")).PUT <@ (user1) + val updateReq = (v4_0_0_Request / "management" / "dynamic-resource-docs" / docId).PUT <@ (user1) val updateResp = makePutRequest(updateReq, - write(posted.copy(dynamicResourceDocId = created.dynamicResourceDocId, methodBody = changedMethodBody))) + write(posted.copy(dynamicResourceDocId = Some(docId), methodBody = changedMethodBody))) updateResp.code should equal(200) - val updated = updateResp.body.extract[JsonDynamicResourceDoc] Then("created_by_user_id is preserved, updated_by_user_id is recorded, and the hash reflects the new body") - updated.createdByUserId should be(Some(resourceUser1.userId)) - updated.updatedByUserId should be(Some(resourceUser1.userId)) - updated.methodBodyHash should be(Some(code.api.util.APIUtil.sha256Hex( - URLDecoder.decode(changedMethodBody, "UTF-8")))) - updated.methodBodyHash shouldNot be(created.methodBodyHash) + storedRow.CreatedByUserId.get should be(resourceUser1.userId) + storedRow.UpdatedByUserId.get should be(resourceUser1.userId) + storedRow.MethodBodyHash.get should be(code.api.util.APIUtil.sha256Hex(URLDecoder.decode(changedMethodBody, "UTF-8"))) } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 37411b2d08..5dbecd26a8 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -2218,6 +2218,96 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { case _ => fail("Expected JSON object") } + // ─── Dynamic-code provenance (v7.0.0 read-only) ────────────────────────────── + feature("Http4s700 dynamic-code provenance endpoints") { + + scenario("Dynamic Resource Docs: 401 unauth, 403 no role, 200 with role exposes provenance", Http4s700RoutesTag) { + Given("A dynamic resource doc seeded with resourceUser1 as creator") + val seeded = code.dynamicResourceDoc.DynamicResourceDocProvider.provider.vend.create( + None, + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy( + dynamicResourceDocId = None, bankId = None, + partialFunctionName = "provenanceV7Test", requestUrl = "/provenance_v7/PV_ID"), + Some(resourceUser1.userId) + ).openOrThrowException("seed dynamic resource doc") + val docId = seeded.dynamicResourceDocId.getOrElse(fail("seeded id")) + val expectedHash = code.api.util.APIUtil.sha256Hex(seeded.decodedMethodBody) + + When("Unauthenticated GET of the list") + val (unauthCode, _, _) = makeHttpRequest("/obp/v7.0.0/management/dynamic-resource-docs") + Then("401") + unauthCode shouldBe 401 + + When("Authenticated but without the role") + val (forbiddenCode, forbiddenJson, _) = makeHttpRequest( + "/obp/v7.0.0/management/dynamic-resource-docs", Map("DirectLogin" -> s"token=${token2.value}")) + Then("403 naming the required role") + forbiddenCode shouldBe 403 + messageOf(forbiddenJson) should include(code.api.util.ApiRole.canGetAllDynamicResourceDocs.toString) + + When("Authenticated with the getAll role") + addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetAllDynamicResourceDocs.toString) + val (okCode, okJson, _) = makeHttpRequest( + "/obp/v7.0.0/management/dynamic-resource-docs", Map("DirectLogin" -> s"token=${token1.value}")) + Then("200 and the seeded doc carries provenance (creator + method_body hash), not on the frozen v4 doc object") + okCode shouldBe 200 + val item = (okJson \ "dynamic_resource_docs") match { + case JArray(items) => items.find(i => (i \ "dynamic_resource_doc" \ "dynamic_resource_doc_id") == JString(docId)) + .getOrElse(fail("seeded doc not in list")) + case _ => fail("dynamic_resource_docs should be an array") + } + (item \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId) + (item \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash) + + When("GET by id with the get role") + addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetDynamicResourceDoc.toString) + val (byIdCode, byIdJson, _) = makeHttpRequest( + s"/obp/v7.0.0/management/dynamic-resource-docs/$docId", Map("DirectLogin" -> s"token=${token1.value}")) + Then("200 with provenance and the unchanged v4 doc shape nested under dynamic_resource_doc") + byIdCode shouldBe 200 + (byIdJson \ "dynamic_resource_doc" \ "dynamic_resource_doc_id") shouldBe JString(docId) + (byIdJson \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId) + (byIdJson \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash) + } + + scenario("Connector Methods: GET by id exposes provenance", Http4s700RoutesTag) { + val seeded = code.connectormethod.ConnectorMethodProvider.provider.vend.create( + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.jsonScalaConnectorMethod.copy( + connectorMethodId = None, methodName = "getBanks"), + Some(resourceUser1.userId) + ).openOrThrowException("seed connector method") + val id = seeded.connectorMethodId.getOrElse(fail("seeded id")) + val expectedHash = code.api.util.APIUtil.sha256Hex(seeded.decodedMethodBody) + + addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetConnectorMethod.toString) + val (code200, json, _) = makeHttpRequest( + s"/obp/v7.0.0/management/connector-methods/$id", Map("DirectLogin" -> s"token=${token1.value}")) + code200 shouldBe 200 + (json \ "connector_method" \ "connector_method_id") shouldBe JString(id) + (json \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId) + (json \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash) + } + + scenario("Dynamic Message Docs: GET by id exposes provenance", Http4s700RoutesTag) { + val seeded = code.dynamicMessageDoc.DynamicMessageDocProvider.provider.vend.create( + None, + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.jsonDynamicMessageDoc.copy( + dynamicMessageDocId = None, bankId = None, process = "obp.provenanceV7Process"), + Some(resourceUser1.userId) + ).openOrThrowException("seed dynamic message doc") + val id = seeded.dynamicMessageDocId.getOrElse(fail("seeded id")) + val expectedHash = code.api.util.APIUtil.sha256Hex(seeded.decodedMethodBody) + + addEntitlement("", resourceUser1.userId, code.api.util.ApiRole.canGetDynamicMessageDoc.toString) + val (code200, json, _) = makeHttpRequest( + s"/obp/v7.0.0/management/dynamic-message-docs/$id", Map("DirectLogin" -> s"token=${token1.value}")) + code200 shouldBe 200 + (json \ "dynamic_message_doc" \ "dynamic_message_doc_id") shouldBe JString(id) + (json \ "provenance" \ "created_by_user_id") shouldBe JString(resourceUser1.userId) + (json \ "provenance" \ "method_body_hash") shouldBe JString(expectedHash) + } + } + feature("Http4s700 attachOpenCorridorPromise (promise report-back) endpoint") { scenario("Reject unauthenticated POST", Http4s700RoutesTag) {