Skip to content

Commit 171819a

Browse files
authored
fix: let the ReadBalances view read an account, which the balances endpoints need it to (#82)
SYSTEM_READ_BALANCES_VIEW_PERMISSION was {can_see_bank_account_balance, can_query_available_funds} -- the honest set for a view whose whole job is balances, and one the UK balances endpoints cannot use: GET /open-banking/v4.0.1/aisp/accounts/{id}/balances 400 OBP-20022: View does not permit the access. You need the `can_see_transaction_this_bank_account` permission on the view(ReadBalances) ViewExtended.moderateAccountCore gates the whole ModeratedBankAccount on that one permission, whatever field the caller wants, and both balances endpoints (v3.1 and v4.0.1) reach the account through moderatedBankAccountCore. So the view is added to the set, with a comment saying why a transaction-named permission is in a balances view and pointing at the gate as the thing that actually wants fixing (issue #81). Measured rather than assumed: a matrix of every UK and Berlin Group view against every endpoint that reads through it found 2 broken combinations out of 12, both the UK balances endpoint. Berlin Group balances was unaffected -- it does not moderate. So exactly one set changes. Not caused by the reconciliation that shipped in #80, and not confined to upgraded installations: a fresh install creates ReadBalances from this same constant, so the endpoint was already broken there. #80 extended that to upgraded installs by making the code-defined set apply. Both are fixed by fixing the set. Three checks covered this area and none could catch it, which is the part worth fixing beyond the one-line set: - MappedViewsTest asserted each view's allowed_actions equals the constant that defines it -- the constant compared with itself, true whatever it says; - the in-repo UK balances test asserts only 401 and 403, so it never reads a balance and never reaches the gate; - the probe matrix ran against a database whose ReadBalances still carried the pre-existing 74-permission generic set, so the code-defined set was exercised nowhere. So MappedViewsTest gains a scenario that calls the gate: for the view the balances endpoints moderate through, assert moderateAccountCore succeeds. Scoped to that one view because grepping the callers of moderatedBankAccountCore shows nothing else moderates an account; the other UK/BG views cannot either, which is latent rather than broken and is recorded in the test rather than asserted. Two existing assertions were weakened and say so at the site. "Balances must not carry transaction- or counterparty-visibility permissions" was an exact-set equality; it is now: must contain balance and available funds, must not contain transaction amount/type/dates, must contain nothing about the other party, and the transaction-named permissions it holds must be exactly the one gate. Rewriting it to equal whatever the constant contains would have discarded the property it exists for. MappedViewsTest 8/8, full local suite 3383/0. Against a running instance: the view/endpoint matrix is 12/12 (was 10/12), the probe matrix 105/0 (was 105/2), and the token-path, stale-revoke and direction probes re-run green after the permission set changed.
1 parent ea64d62 commit 171819a

2 files changed

Lines changed: 88 additions & 6 deletions

File tree

‎obp-api/src/main/scala/code/api/constant/constant.scala‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,9 +666,20 @@ object Constant extends MdcLoggable {
666666
CAN_SEE_BANK_ACCOUNT_ROUTING_ADDRESS
667667
)
668668

669+
// CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT is not here to expose transactions -- this view shows
670+
// none. It is here because View.moderateAccount gates the whole ModeratedBankAccount on it,
671+
// whatever field the caller actually wants, and the UK balances endpoint reaches the account
672+
// through moderatedBankAccountCore. Without it, GET /aisp/accounts/ACCOUNT_ID/balances answers
673+
// OBP-20022 "You need the `can_see_transaction_this_bank_account` permission on the
674+
// view(ReadBalances)" -- the endpoint is unusable for the one thing the view exists for.
675+
//
676+
// That gate is the thing worth fixing: a permission named for transactions should not decide
677+
// whether an account can be read at all. Doing it properly means changing every view and every
678+
// caller of moderateAccount, so it is tracked separately rather than smuggled in here.
669679
final val SYSTEM_READ_BALANCES_VIEW_PERMISSION = List(
670680
CAN_SEE_BANK_ACCOUNT_BALANCE,
671-
CAN_QUERY_AVAILABLE_FUNDS
681+
CAN_QUERY_AVAILABLE_FUNDS,
682+
CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT
672683
)
673684

674685
final val SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION = List(

‎obp-api/src/test/scala/code/views/MappedViewsTest.scala‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ package code.views
22

33
import code.api.Constant
44
import code.api.util.ErrorMessages.ViewIdNotSupported
5+
import code.model.ViewExtended
56
import code.setup.{DefaultUsers, ServerSetup}
67
import code.views.system.{ViewDefinition, ViewPermission}
7-
import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, ViewId}
8+
import com.openbankproject.commons.model.{AccountId, BankAccountCommons, BankId, BankIdAccountId, ViewId}
89
import net.liftweb.common.{Empty, Failure}
910

1011
class MappedViewsTest extends ServerSetup with DefaultUsers{
@@ -125,8 +126,27 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{
125126
Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet should contain allElementsOf Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION
126127
Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet.size should be > Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION.toSet.size
127128

128-
Then("Balances must not carry transaction- or counterparty-visibility permissions")
129-
actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should equal(Set(Constant.CAN_SEE_BANK_ACCOUNT_BALANCE, Constant.CAN_QUERY_AVAILABLE_FUNDS))
129+
// This assertion used to read `should equal(Set(BALANCE, AVAILABLE_FUNDS))`. It was weakened
130+
// on purpose and the reason is recorded rather than quietly absorbed: the set now also
131+
// carries CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT, without which the view cannot moderate an
132+
// account at all and the balances endpoint answers OBP-20022 instead of a balance (see the
133+
// moderation scenario below). It is a gate, not a field -- so what the view must still not
134+
// carry is the transaction *content* and anything about the other party, and that is what is
135+
// asserted now. Rewriting this to match whatever the constant happens to contain would have
136+
// thrown away the property the scenario exists for.
137+
Then("Balances must carry no transaction content and nothing about the other party")
138+
val balances = actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID)
139+
balances should contain(Constant.CAN_SEE_BANK_ACCOUNT_BALANCE)
140+
balances should contain(Constant.CAN_QUERY_AVAILABLE_FUNDS)
141+
balances should not contain Constant.CAN_SEE_TRANSACTION_AMOUNT
142+
balances should not contain Constant.CAN_SEE_TRANSACTION_TYPE
143+
balances should not contain Constant.CAN_SEE_TRANSACTION_START_DATE
144+
balances should not contain Constant.CAN_SEE_TRANSACTION_FINISH_DATE
145+
balances.filter(_.contains("other_account")) shouldBe empty
146+
balances.filter(_.contains("counterparty")) shouldBe empty
147+
// The one transaction-named permission it may hold, and only that one.
148+
balances.filter(_.startsWith("can_see_transaction")) should
149+
equal(Set(Constant.CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT))
130150
}
131151

132152
/**
@@ -180,9 +200,12 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{
180200
withClue(s"$viewId: ") { permissionsOf(viewId) should equal(expected.toSet) }
181201
}
182202

183-
And("Balances in particular no longer carries transaction or counterparty visibility")
203+
And("Balances in particular no longer carries transaction content or counterparty visibility")
204+
// Same weakening as the scenario above, for the same reason: the only transaction-named
205+
// permission this view may hold is the moderation gate, which reveals nothing by itself.
184206
val balances = permissionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID)
185-
balances.filter(_.contains("transaction")) shouldBe empty
207+
balances.filter(_.startsWith("can_see_transaction")) should
208+
equal(Set(Constant.CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT))
186209
balances.filter(_.contains("other_account")) shouldBe empty
187210
}
188211

@@ -204,6 +227,54 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{
204227
}
205228
}
206229

230+
/**
231+
* The scenarios above compare each view's permission set against the constant that defines it,
232+
* which is the constant compared with itself: they cannot tell whether a set is one an endpoint
233+
* can actually use. That gap shipped a real defect. SYSTEM_READ_BALANCES_VIEW_PERMISSION was
234+
* exactly {balance, available funds}, and the UK balances endpoint answered
235+
*
236+
* OBP-20022 ... You need the `can_see_transaction_this_bank_account` permission on the
237+
* view(ReadBalances)
238+
*
239+
* because ViewExtended.moderateAccountCore gates the whole ModeratedBankAccount on that one
240+
* permission, whatever field the caller wants. Every existing check stayed green: the view
241+
* assertions compared the set with itself, the in-repo balances test only asserts 401/403 and
242+
* never reads a balance, and the probe suite ran against a database whose ReadBalances still
243+
* carried the old 74-permission set, so the code-defined one was exercised nowhere.
244+
*
245+
* So assert the property that actually matters -- the view can moderate an account -- by
246+
* calling the gate rather than by reading the constant back.
247+
*/
248+
scenario("the view the balances endpoints moderate an account through can actually do it") {
249+
// A plain value: moderateAccountCore reads fields off it and does not go to the database,
250+
// so the gate under test is reached without standing up an account fixture.
251+
val account = BankAccountCommons(
252+
accountId = AccountId("moderation-test-account"), accountType = "CURRENT",
253+
balance = BigDecimal("1.00"), currency = "EUR", name = "moderation test",
254+
label = "moderation test", number = "1", bankId = BankId("moderation-test-bank"),
255+
lastUpdate = new java.util.Date(), branchId = "", accountRoutings = Nil,
256+
accountRules = Nil, accountHolder = "")
257+
// Exactly the views an endpoint reaches an account through, established by grepping the
258+
// callers of moderatedBankAccountCore rather than assumed: the UK v3.1 and v4.0.1 balances
259+
// endpoints, both of which resolve ReadBalances. Nothing else moderates an account.
260+
//
261+
// ReadAccountsBasic, ReadAccountsDetail and the two Berlin Group views cannot moderate one
262+
// either -- none of them carries the gate. That is latent rather than broken, because no
263+
// endpoint asks them to: the UK account reads resolve their fields without moderating, and
264+
// Berlin Group has its own path. Asserting it here would either freeze that as correct or
265+
// fail for endpoints that do not exist, so it is recorded and not asserted. If a future
266+
// endpoint starts moderating through one of them, this list is where to add it.
267+
val moderated = List(Constant.SYSTEM_READ_BALANCES_VIEW_ID)
268+
moderated.foreach { viewId =>
269+
val view = MapperViews.getOrCreateSystemView(viewId)
270+
.openOrThrowException(s"$viewId should be a known system view")
271+
withClue(s"$viewId cannot moderate an account, so every endpoint reading one through it " +
272+
s"answers OBP-20022 rather than data: ") {
273+
ViewExtended(view).moderateAccountCore(account).isDefined should equal(true)
274+
}
275+
}
276+
}
277+
207278
scenario("a view the code does not define is left alone") {
208279
val owner = MapperViews.getOrCreateSystemView(Constant.SYSTEM_OWNER_VIEW_ID)
209280
.openOrThrowException("owner should be a known system view")

0 commit comments

Comments
 (0)