Skip to content

Commit 93aca85

Browse files
authored
Merge pull request #2875 from simonredfern/develop
UK consent test not cross standard consent
2 parents cd55cec + 46af397 commit 93aca85

13 files changed

Lines changed: 370 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Note: `executeFutureCreated` returns 201; pair it with `cc.user.openOrThrowExcep
241241

242242
**Use `NEW_ACCOUNT_ID` for PUT-creates-account URLs**: When a `PUT /banks/BANK_ID/accounts/ACCOUNT_ID` *creates* the account (it doesn't exist yet), the middleware's `validateAccount` keys off the literal `ACCOUNT_ID` template var and tries to look it up → 404 before the handler runs. Change the ResourceDoc URL template to `/banks/BANK_ID/accounts/NEW_ACCOUNT_ID` (or any non-standard ALL_CAPS variant) — middleware treats it as a wildcard and skips the lookup, but the path still matches the route pattern. The handler can check "already exists" inline with `Connector.connector.vend.checkBankAccountExists(...)` and return 409/400 as needed.
243243

244-
**Reserved ALL_CAPS literals — don't use them as placeholders**: `ResourceDocMatcher` in `Http4sSupport.scala` keeps an explicit `literalAllCapsSegments` set: `SANDBOX_TAN`, `COUNTERPARTY`, `SEPA`, `FREE_FORM`, `ACCOUNT`, `ACCOUNT_OTP`, `REFUND`, `SIMPLE`, `AGENT_CASH_WITHDRAWAL`, `CARD`, `EMAIL`, `SMS`, `IMPLICIT`, `NOT_EMAIL_NEITHER_SMS`. These are matched as **literals** (real Lift endpoints register them as concrete SCA-method / transaction-request-type segments — e.g. `/banks/BANK_ID/my/consents/EMAIL`). Any other ALL_CAPS segment is a wildcard. If you migrate an endpoint whose URL template uses one of these names as a *placeholder variable* (e.g. v3.0/v4.0 `getUsersByEmail` had `/users/email/EMAIL/terminator` with EMAIL meaning "any email value"), the matcher will only fire when the URL segment is literally `EMAIL` — real callers pass actual addresses and miss the doc entirely → middleware skips auth/role validation → handler 500s on the empty CallContext. Rename the placeholder to something outside the literal set (e.g. `EMAIL` → `USER_EMAIL`), and apply the rename in **both** the http4s `ResourceDoc` and the original Lift `ResourceDoc` (resource-docs aggregation reads both, and `collectResourceDocs` dedup keys off URL + verb).
244+
**Reserved ALL_CAPS literals — don't use them as placeholders**: `ResourceDocMatcher` in `Http4sSupport.scala` keeps an explicit `literalAllCapsSegments` set: `SANDBOX_TAN`, `COUNTERPARTY`, `SEPA`, `FREE_FORM`, `ACCOUNT`, `ACCOUNT_OTP`, `REFUND`, `SIMPLE`, `AGENT_CASH_WITHDRAWAL`, `CARD`, `OPEN_CORRIDOR_PROMISE`, `OPEN_CORRIDOR_SETTLEMENT`, `EMAIL`, `SMS`, `IMPLICIT`, `NOT_EMAIL_NEITHER_SMS`. These are matched as **literals** (real Lift endpoints register them as concrete SCA-method / transaction-request-type segments — e.g. `/banks/BANK_ID/my/consents/EMAIL`). Any other ALL_CAPS segment is a wildcard. If you migrate an endpoint whose URL template uses one of these names as a *placeholder variable* (e.g. v3.0/v4.0 `getUsersByEmail` had `/users/email/EMAIL/terminator` with EMAIL meaning "any email value"), the matcher will only fire when the URL segment is literally `EMAIL` — real callers pass actual addresses and miss the doc entirely → middleware skips auth/role validation → handler 500s on the empty CallContext. Rename the placeholder to something outside the literal set (e.g. `EMAIL` → `USER_EMAIL`), and apply the rename in **both** the http4s `ResourceDoc` and the original Lift `ResourceDoc` (resource-docs aggregation reads both, and `collectResourceDocs` dedup keys off URL + verb).
245245

246246
**Bypass roles vs required roles**: Some Lift handlers check entitlements inline as **bypass** conditions inside authorisation helpers — e.g. `checkAuthorisationToCreateTransactionRequest` honours `canCreateAnyTransactionRequest` to let the caller skip the view-permission check, but the role is never a hard requirement. These roles are correctly absent from the Lift ResourceDoc role list — putting them in the doc would make Lift enforce them as required (since Lift DOES enforce doc roles by default), breaking the "view permission OR role" intent. The same holds for http4s middleware. So the trap on migration is the reflex copy: don't move a bypass role from inline-only into `Some(List(...))` just because it appears in the handler. Audit before copying: if the role appears in the Lift handler only inside an authorisation OR-chain ("has view permission OR has role X"), it belongs as `None` in the doc with the inline view/role logic preserved. Bypass roles must stay out of the doc.
247247

ideas/VIEWS_ACROSS_STANDARDS.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Views Across API Standards (OBP native, Berlin Group, UK Open Banking)
2+
3+
Status: idea / analysis (2026-07-16). Question under discussion: **is it good to have views
4+
explicitly and exactly named after UK and BG consents?**
5+
6+
Short answer: **no for Berlin Group, accidentally-yes for UK.** A view should be named for the
7+
*capability it grants* (what data is visible), not for the *standard/channel that asked*.
8+
9+
## 1. Current state — how each standard uses views
10+
11+
The shared spine is identical everywhere: a consent carries a list of
12+
`(bank_id, account_id, view_id)` triples (`ConsentJWT.views: List[ConsentView]`,
13+
`ConsentUtil.scala:55`), authorization grants the user real access to those views
14+
(`grantAccessToViews`, `ConsentUtil.scala:373-399` — revoke-then-regrant per view), and at
15+
request time the endpoint resolves a `ViewId`, checks access, and uses the returned `View` to
16+
moderate the response. What differs per standard is *which* view IDs flow through that pipe and
17+
*who chooses them*.
18+
19+
### OBP native (v1.2.1 → v7.0.0)
20+
The view is a first-class URL parameter (`VIEW_ID`), resolved by middleware into `cc.view`.
21+
The caller picks the view; nothing is hardcoded. Arbitrary system and custom views.
22+
23+
### Berlin Group v1.3
24+
Fully view-gated, but through a **dedicated BG system-view vocabulary**
25+
(`constant.scala:151-169`): `ReadAccountsBerlinGroup`, `ReadBalancesBerlinGroup`,
26+
`ReadTransactionsBerlinGroup`, `InitiatePaymentsBerlinGroup`.
27+
28+
- The BG consent `access` object is translated into `ConsentView`s keyed to those views —
29+
`createBerlinGroupConsentJWT` / `updateBerlinGroupConsentJWT`
30+
(`ConsentUtil.scala:823-855, 913-1015`), IBAN → `(bankId, accountId)` resolution via
31+
`getBankAccountByIban`.
32+
- Each AIS endpoint hardcodes the matching view: balances →
33+
`SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID` (`Http4sBGv13AIS.scala:216, 266`), transactions →
34+
`SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID` (`:287-289, :380-382`), accounts →
35+
`SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID` (`:439, :468`).
36+
- The TPP never names a view; the standard's consent scopes map onto them.
37+
38+
### UK Open Banking v3.1 / v4.0.1
39+
Also view-gated, but reusing the **generic** `Read*` system views with a detail-or-basic
40+
fallback:
41+
42+
- Accounts: `SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID` `.or(BASIC)` via `checkViewAccessAndReturnView`
43+
(`Http4sUKOBv310Accounts.scala:44-58`; `Http4sUKOBv401AccountInfo.scala:324-338`).
44+
- Balances: `SYSTEM_READ_BALANCES_VIEW_ID` (`Http4sUKOBv310Balances.scala:111-116`).
45+
- Transactions: detail/basic pair via `checkViewsAccessAndReturnView`
46+
(`Http4sUKOBv310Transactions.scala:308-315`; `Http4sUKOBv401AccountInfo.scala:2218`).
47+
UKOB additionally checks the consent itself (`checkUKConsent`) + `passesPsd2Aisp` before the
48+
view check.
49+
- Consent side: `createConsentJWT` (UK path, `ConsentUtil.scala:1040-1104`) sets
50+
**`view_id = permission`** — the OBUK permission strings become the granted view IDs directly
51+
(`:1063-1088`). If `bankId`/`accountIds` are absent it creates account-unscoped grants with
52+
`bank_id = null, account_id = null` (`:1078-1085`).
53+
54+
### Known inconsistencies (as of 2026-07-16)
55+
1. **BG v2 AIS endpoints are pure mocks** — no auth, no consent, no view check
56+
(`Http4sBGv2AIS.scala:49-135`). If BG v2 goes live, the whole consent→view chain from v1.3
57+
needs porting.
58+
2. **UKOB v2.0.0 hardcodes the `"owner"` view** for all AIS endpoints
59+
(`Http4sUKOBv200AIS.scala:130, 150`) — a TPP needs full owner access instead of a scoped
60+
read view.
61+
3. **Two parallel PSD2 view vocabularies** for the same concept: BG's `*BerlinGroup` views vs
62+
UK's generic `Read*` views.
63+
4. **UK `createConsentJWT` trusts the raw permission string as a view ID** and can create
64+
account-unscoped grants — correctness rests entirely on callers passing valid view IDs.
65+
5. BG v1.3 uses two different access-check helpers (a local `hasViewAccess`-based
66+
`checkAccountAccess` for accounts/balances at `Http4sBGv13AIS.scala:57-62` vs
67+
`ViewNewStyle.checkAccountAccessAndGetView` for transactions). Both enforce, but it's two
68+
code paths for one job.
69+
6. Legacy Lift BG v1.3 (`AccountInformationServiceAISApi.scala`) is entirely commented-out dead
70+
code — the live logic is `Http4sBGv13AIS.scala`.
71+
72+
## 2. What the permission definitions reveal
73+
74+
`MapperViews.applyDefaultsForSystemView` (`MapperViews.scala:757-819`):
75+
76+
- All six generic read views — `ReadAccountsBasic`, `ReadAccountsDetail`, `ReadBalances`,
77+
`ReadTransactionsBasic`, `ReadTransactionsDebits`, `ReadTransactionsDetail` — get the
78+
**identical** permission set, `SYSTEM_VIEW_PERMISSION_COMMON` (same as `accountant`). The
79+
basic/detail distinction the names promise is not yet backed by differing permissions.
80+
- `ReadAccountsBerlinGroup` and `ReadBalancesBerlinGroup` get **no permissions at all** — a bare
81+
entity (`MapperViews.scala:785-787`). Only the BG transactions and payments views have tuned
82+
sets.
83+
84+
So the standard-branded names are not buying differentiated moderation today. The BG views are
85+
mostly a parallel namespace whose content is empty or duplicative — the naming carries all the
86+
weight, and naming-by-standard is the wrong axis.
87+
88+
## 3. Why standard-named views are the wrong axis
89+
90+
1. **It scales per-standard, not per-capability.** "Read balances" means the same thing in BG,
91+
UK, STET, and whatever comes next. Standard-named views mean every new standard mints
92+
another view set, and "who can see balances on this account?" becomes a query over N views
93+
that must be kept semantically in sync by hand.
94+
2. **Views are OBP's primary access-control model and they're user-visible** — they appear in
95+
account view lists and in grants. Leaking regulatory brand names into that vocabulary couples
96+
the core ACL model to external specs' branding and lifecycle (a BG rename in a new spec
97+
version → stale view ID or a migration).
98+
3. **The real per-standard differences live elsewhere.** BG and UK responses differ in *shape*,
99+
but shape is handled in each standard's JSONFactory. The view only controls *which underlying
100+
data is accessible*, and at that layer the standards barely differ.
101+
102+
## 4. Why the UK naming is fine anyway
103+
104+
The UK permission names (`ReadAccountsDetail`, `ReadBalances`, `ReadTransactionsBasic`, …)
105+
happen to *be* a good generic capability vocabulary — they describe data visibility, not a
106+
channel. That's why reusing them as shared system views works, and why UKOB v3.1/v4.0.1 feel
107+
cleaner than BG v1.3.
108+
109+
The wrinkle is the mechanism, not the names: `createConsentJWT` passes the raw permission
110+
string through as the `view_id`, so the UK spec's vocabulary directly owns part of the view
111+
namespace. It works because the names are good; the right shape is an explicit
112+
permission → view mapping table, so a future spec rename doesn't silently mint new views.
113+
114+
## 5. The one honest argument *for* segregation
115+
116+
Grants are per `(user, view, account)`, and `grantAccessToViews` revokes-then-regrants on
117+
consent authorization. If BG and UK consents shared view IDs, revoking a BG consent could
118+
clobber access a still-live UK consent granted on the same view+account. The separate BG
119+
namespace accidentally shields against *cross-standard* interference — though not against two
120+
consents *within* the same standard, so it's a partial fix for a real bug in the wrong layer.
121+
122+
The proper fix is consent-scoped grant accounting (or checking the consent, not just the view,
123+
at request time — which the UK path already partly does via `checkUKConsent`). Once that
124+
exists, the last reason for standard-named views disappears.
125+
126+
## 6. Recommendation
127+
128+
1. **Converge on one capability-named read-view vocabulary** — the existing `Read*` set.
129+
2. **Give basic vs detail genuinely different permission sets** so the names mean something
130+
(today all six share `SYSTEM_VIEW_PERMISSION_COMMON`).
131+
3. **Retarget BG's consent translation at the shared views** — the translation layer in
132+
`createBerlinGroupConsentJWT` already exists; it just points at the duplicate views. Treat
133+
the `*BerlinGroup` views as deprecated, with a migration for existing grants
134+
(`AccountAccess` rows referencing the old view IDs).
135+
4. **Fix the grant-interference bug at the right layer**: consent-scoped grant accounting or
136+
request-time consent checks, not view-namespace segregation.
137+
5. **Make the UK permission → view mapping explicit** instead of `view_id = permission`
138+
passthrough, and validate/scope the account-unscoped (`null` bank/account) grant path.
139+
6. Standard-specific code should be exactly two things per standard: its
140+
consent-scope → view mapping, and its JSON factory. Never the view names themselves.

obp-api/src/main/scala/code/api/util/ConsentUtil.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,11 @@ object Consent extends MdcLoggable {
11451145
* Every live creator now stamps a standard, so blank means "old row" — backfill mApiStandard
11461146
* to 'obp' to remove the grandfathering path and tighten to strict equality later.
11471147
*/
1148+
// Test seam: assert the standard by consentId (used by ConsentStandardBoundaryTest).
1149+
def assertConsentStandardById(consentId: String, expectedStandard: String): Option[Failure] =
1150+
Consents.consentProvider.vend.getConsentByConsentId(consentId).toOption
1151+
.flatMap(assertConsentStandard(_, expectedStandard))
1152+
11481153
def assertConsentStandard(storedConsent: MappedConsent, expectedStandard: String): Option[Failure] = {
11491154
val actualStandard = Option(storedConsent.apiStandard).map(_.trim).getOrElse("")
11501155
if (actualStandard.isEmpty) {

obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,7 @@ object ResourceDocMatcher extends code.util.Helper.MdcLoggable {
741741
"SANDBOX_TAN", "COUNTERPARTY", "SEPA", "FREE_FORM",
742742
"ACCOUNT", "ACCOUNT_OTP", "REFUND", "SIMPLE",
743743
"AGENT_CASH_WITHDRAWAL", "CARD",
744+
"OPEN_CORRIDOR_PROMISE", "OPEN_CORRIDOR_SETTLEMENT",
744745
// SCA methods (POST /banks/BANK_ID/my/consents/{EMAIL|SMS|IMPLICIT})
745746
"EMAIL", "SMS", "IMPLICIT", "NOT_EMAIL_NEITHER_SMS"
746747
)

obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3175,14 +3175,14 @@ object Http4s700 {
31753175

31763176
// ── End UTILITY ───────────────────────────────────────────────────────────
31773177

3178-
// ── OPEN_CORRIDOR transaction request ─────────────────────────────────────
3178+
// ── OPEN_CORRIDOR_PROMISE transaction request ─────────────────────────────
31793179
// Travel-Rule-friendly TR with FATF Recommendation 16 originator block.
31803180
// Money-movement is identical to SIMPLE; the originator is persisted as a
31813181
// side-car on the TR row and surfaced on the v7 response. Lives natively at
31823182
// v7 (rather than bridging to v4) because only v7's response shape carries
31833183
// the originator block.
31843184
val createTransactionRequestOpenCorridor: HttpRoutes[IO] = HttpRoutes.of[IO] {
3185-
case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / "OPEN_CORRIDOR" / "transaction-requests" =>
3185+
case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / "OPEN_CORRIDOR_PROMISE" / "transaction-requests" =>
31863186
EndpointHelpers.withViewAndBodyCreated[JSONFactory700.TransactionRequestBodyOpenCorridorJsonV700, JSONFactory700.TransactionRequestWithChargeOpenCorridorJsonV700](req) { (user, fromAccount, view, body, cc) =>
31873187
val callCtx = Some(cc)
31883188
for {
@@ -3211,7 +3211,7 @@ object Http4s700 {
32113211
other_branch_routing_address = ""
32123212
),
32133213
value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "EUR", amount = "100.00"),
3214-
description = "OPEN_CORRIDOR Travel-Rule payment",
3214+
description = "Open Corridor Travel-Rule payment",
32153215
charge_policy = "SHARED",
32163216
originator = com.openbankproject.commons.model.TransactionRequestOriginator(
32173217
name = "Alice Sender",
@@ -3228,17 +3228,17 @@ object Http4s700 {
32283228
implementedInApiVersion,
32293229
nameOf(createTransactionRequestOpenCorridor),
32303230
"POST",
3231-
"/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/OPEN_CORRIDOR/transaction-requests",
3232-
"Create Transaction Request (OPEN_CORRIDOR)",
3233-
"""Initiate an OPEN_CORRIDOR Transaction Request — a Travel-Rule-friendly payment that carries FATF Recommendation 16 originator information about the actual payer.
3231+
"/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/OPEN_CORRIDOR_PROMISE/transaction-requests",
3232+
"Create Transaction Request (OPEN_CORRIDOR_PROMISE)",
3233+
"""Initiate an OPEN_CORRIDOR_PROMISE Transaction Request — an Open Corridor Travel-Rule-friendly payment that carries FATF Recommendation 16 originator information about the actual payer.
32343234
|
32353235
|Money-movement is identical to the SIMPLE transaction request type (same beneficiary routing fields). What's distinct: the `originator` block is mandatory and is persisted alongside the transaction request. The v7 response includes a populated originator block.
32363236
|
32373237
|Authentication is Required.""".stripMargin,
32383238
openCorridorBodyExample,
32393239
JSONFactory700.TransactionRequestWithChargeOpenCorridorJsonV700(
32403240
id = "4050046c-63b3-4868-8a22-14b4181d33a6",
3241-
`type` = "OPEN_CORRIDOR",
3241+
`type` = "OPEN_CORRIDOR_PROMISE",
32423242
from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140(
32433243
bank_id = "gh.29.uk",
32443244
account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f1"
@@ -3271,7 +3271,7 @@ object Http4s700 {
32713271
http4sPartialFunction = Some(createTransactionRequestOpenCorridor)
32723272
)
32733273

3274-
// ── End OPEN_CORRIDOR ─────────────────────────────────────────────────────
3274+
// ── End OPEN_CORRIDOR_PROMISE ─────────────────────────────────────────────
32753275

32763276
// ── BULK transaction request ──────────────────────────────────────────────
32773277
// One TransactionRequest with type=BULK serves as the envelope; N actual

obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,7 +1011,7 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
10111011
)
10121012
}
10131013

1014-
// ─── OPEN_CORRIDOR Transaction Request type ────────────────────────────────
1014+
// ─── OPEN_CORRIDOR_PROMISE Transaction Request type ────────────────────────
10151015
//
10161016
// SIMPLE-shaped beneficiary routing plus a REQUIRED `originator` block carrying
10171017
// FATF Recommendation 16 (Travel Rule) information about the actual payer. The
@@ -1042,7 +1042,7 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
10421042
address: String
10431043
)
10441044

1045-
// OPEN_CORRIDOR response wrapper — v4 TransactionRequestWithChargeJSON400 shape
1045+
// OPEN_CORRIDOR_PROMISE response wrapper — v4 TransactionRequestWithChargeJSON400 shape
10461046
// plus the originator block. `originator` is None when there's no explicit value
10471047
// stored AND no customer_account_link for the from-account; serializes as null.
10481048
case class TransactionRequestWithChargeOpenCorridorJsonV700(

0 commit comments

Comments
 (0)