Skip to content

Commit 4aac90a

Browse files
authored
fix: keep the code-defined system views current on every installation, not only fresh ones (#80)
The UK Open Banking and Berlin Group views carry can_* sets defined in Constant.SYSTEM_READ_*_VIEW_PERMISSION, and applyDefaultsForSystemView writes them. It runs from exactly two places: unsavedSystemView, at creation, and factoryResetSystemView, an admin endpoint. Boot calls getOrCreateSystemView, which returns an existing row untouched. So a view created by an older version keeps that version's permission set for good. Tightening a set in code reaches new installations and no others -- which is how "ReadAccountsDetail granted nothing beyond ReadAccountsBasic" and "ReadBalances alone exposed transaction and counterparty data" could be fixed in code and remain true of every upgraded database. Boot now calls ensureSystemViewUpToDate, which creates the view when missing and otherwise re-applies the code-defined permissions to the existing row. Only the permission set: name, description, isPublic and the alias flags are left alone, so operator edits to those survive an upgrade. Unlike a migration this is not opt-in -- migration_scripts.execute_all defaults to false, so shipping this as a migration would have reproduced the very shape of the defect, a fix that does not apply to the installations that have the problem. system_views.reconcile_permissions_at_boot turns it off for an operator who hand-tunes these rows. Default true, and every view whose set moves is logged with the permissions added and removed. The nine views the code defines sets for are also created unconditionally rather than through additional_system_views. The code already depends on them: a UK consent naming ReadTransactionsCredits cannot be granted if that view is absent, and validateUKConsentPermissions requires a direction permission whenever a transaction-depth one is granted -- so a conforming consent could not be exercised at all on an instance whose props predated the view. ReadTransactionsBerlinGroup and InitiatePaymentsBerlinGroup stay opt-in, since nothing requires them, but are reconciled where they exist: the prop decides whether a view exists, not whether the code is authoritative about what it grants. Written test-first. The new upgrade scenario fails before the change with ReadAccountsBasic still carrying the 74-permission generic set -- including can_see_transaction_amount and can_see_other_account_iban on a view meant to expose four account fields. The scenario that was already there cannot catch this: afterEach drops every ViewDefinition, so it only ever exercises the create path. MappedViewsTest 7/7, full local suite 3378/0 across all four shards. Known gap, not closed here: the two Berlin Group views' real state on a long-lived database is not established. One development database has ReadAccountsBerlinGroup carrying 176 distinct permissions accumulated across three separate dates, which is neither the generic set nor the target. The tests cover create-fresh and upgrade-from-the-generic-set; upgrading from that third shape is untested.
1 parent 2802b3d commit 4aac90a

5 files changed

Lines changed: 203 additions & 20 deletions

File tree

‎obp-api/src/main/resources/props/sample.props.template‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,18 +1293,35 @@ database_messages_scheduler_interval=3600
12931293
# Create System Views At Boot -----------------------------------------------
12941294
# In case is not defined default value is true
12951295
# create_system_views_at_boot=true
1296+
#
1297+
# The UK Open Banking and Berlin Group views the code defines can_* sets for are created
1298+
# unconditionally and no longer need listing here -- the code depends on them existing. A UK
1299+
# consent naming ReadTransactionsCredits cannot be granted if that view is absent, and a
1300+
# conforming consent is *required* to name a direction permission whenever it names a
1301+
# transaction-depth one, so leaving their creation to a props file made conforming consents
1302+
# unusable on instances whose props predated them:
1303+
# ReadAccountsBasic, ReadAccountsDetail, ReadBalances, ReadTransactionsBasic,
1304+
# ReadTransactionsDebits, ReadTransactionsCredits, ReadTransactionsDetail,
1305+
# ReadAccountsBerlinGroup, ReadBalancesBerlinGroup
1306+
#
12961307
# additional_system_views=
1297-
# Possible values for additional_system_views are: ReadAccountsBasic,\
1298-
ReadAccountsDetail,\
1299-
ReadBalances,\
1300-
ReadTransactionsBasic,\
1301-
ReadTransactionsDebits,\
1302-
ReadTransactionsCredits,\
1303-
ReadTransactionsDetail, \
1304-
ReadAccountsBerlinGroup, \
1305-
ReadBalancesBerlinGroup, \
1306-
ReadTransactionsBerlinGroup, \
1308+
# Possible values for additional_system_views are: ReadTransactionsBerlinGroup, \
13071309
InitiatePaymentsBerlinGroup
1310+
#
1311+
# Whether boot brings existing system views' permissions back in line with what this build
1312+
# defines. Default true.
1313+
#
1314+
# getOrCreateSystemView returns an existing row untouched, so before this a view created by an
1315+
# older version kept that version's permission set for good: a set tightened in code reached new
1316+
# installations and no others. That is how "ReadAccountsDetail granted nothing beyond
1317+
# ReadAccountsBasic" and "ReadBalances alone exposed transaction and counterparty data" could be
1318+
# fixed in code and still be true of every upgraded database.
1319+
#
1320+
# Only the permission set is reconciled -- a view's name, description, isPublic and alias flags
1321+
# are left alone, so operator edits to those survive. Set to false only if you deliberately
1322+
# hand-tune these rows and intend to keep them current yourself; every reconciliation is logged
1323+
# with the permissions added and removed.
1324+
# system_views.reconcile_permissions_at_boot=true
13081325
# -----------------------------------------------------------------------------
13091326

13101327

‎obp-api/src/main/scala/bootstrap/liftweb/Boot.scala‎

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,27 +317,57 @@ class Boot extends MdcLoggable {
317317
Views.views.vend.getOrCreateSystemView(SYSTEM_FIREHOSE_VIEW_ID).isDefined
318318
else Empty.isDefined
319319

320+
// The UK Open Banking and Berlin Group views whose can_* sets the code defines
321+
// (Constant.SYSTEM_READ_*_VIEW_PERMISSION). Ensured unconditionally rather than through
322+
// additional_system_views: the code already depends on them existing. A UK consent naming
323+
// ReadTransactionsCredits cannot be granted if that view is absent, and
324+
// validateUKConsentPermissions *requires* a direction permission whenever a
325+
// transaction-depth one is granted -- so a conforming consent could not be exercised on an
326+
// instance whose props predated the view.
327+
//
328+
// ensureSystemViewUpToDate, not getOrCreateSystemView: the latter returns an existing row
329+
// untouched, so a view created by an older version keeps that version's permission set for
330+
// good and a tightening in code reaches new installations only. That is how "Detail granted
331+
// nothing beyond Basic" and "Balances alone exposed transaction data" survived being fixed.
332+
val codeDefinedSystemViews = List(
333+
SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID,
334+
SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID,
335+
SYSTEM_READ_BALANCES_VIEW_ID,
336+
SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID,
337+
SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID,
338+
SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID,
339+
SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID,
340+
SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID,
341+
SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID
342+
)
343+
// Default true: a permission set the code tightened must reach the installations that have
344+
// the problem, not only fresh ones. An operator who has deliberately hand-tuned these rows
345+
// turns it off and takes responsibility for keeping them current.
346+
if (APIUtil.getPropsAsBoolValue("system_views.reconcile_permissions_at_boot", true)) {
347+
codeDefinedSystemViews.foreach(Views.views.vend.ensureSystemViewUpToDate)
348+
} else {
349+
logger.warn("system_views.reconcile_permissions_at_boot is false: the UK Open Banking and " +
350+
"Berlin Group system views keep whatever permissions they already carry, which may be " +
351+
"an older and wider set than this build defines.")
352+
codeDefinedSystemViews.foreach(Views.views.vend.getOrCreateSystemView)
353+
}
354+
355+
// The remaining two stay opt-in: nothing in the code requires them to exist, so whether an
356+
// instance has them is still the operator's call. Their permission sets ARE code-defined
357+
// though, so where they do exist they are kept current the same way -- the prop decides
358+
// existence, not whether the code is authoritative about what a view grants.
320359
APIUtil.getPropsValue("additional_system_views") match {
321360
case Full(value) =>
322361
val additionalSystemViewsFromProps = value.split(",").map(_.trim).toList
323362
val additionalSystemViews = List(
324-
SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID,
325-
SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID,
326-
SYSTEM_READ_BALANCES_VIEW_ID,
327-
SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID,
328-
SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID,
329-
SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID,
330-
SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID,
331-
SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID,
332-
SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID,
333363
SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID,
334364
SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID
335365
)
336366
for {
337367
systemView <- additionalSystemViewsFromProps
338368
if additionalSystemViews.exists(_ == systemView)
339369
} {
340-
Views.views.vend.getOrCreateSystemView(systemView)
370+
Views.views.vend.ensureSystemViewUpToDate(systemView)
341371
}
342372
case _ => // Do nothing
343373
}

‎obp-api/src/main/scala/code/views/MapperViews.scala‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,46 @@ object MapperViews extends Views with MdcLoggable {
717717
}
718718

719719

720+
/**
721+
* Create the view if it is missing, and bring an existing one's permissions back in line with
722+
* `applyDefaultsForSystemView`.
723+
*
724+
* getOrCreateSystemView returns an existing row untouched, and applyDefaultsForSystemView runs
725+
* only from unsavedSystemView (creation) and factoryResetSystemView (an admin endpoint). So a
726+
* view created by an older version keeps that version's permission set for good: tightening a
727+
* set in code reaches new installations and no others.
728+
*
729+
* Re-applied rather than compared-then-applied. resetViewPermissions already deletes before it
730+
* inserts, so the write is idempotent, and at nine views once per boot the cost is not worth a
731+
* second source of truth for what each view's target set is. The before/after comparison is kept
732+
* for the log line only -- an operator upgrading should be able to see exactly which views moved.
733+
*
734+
* A view id applyDefaultsForSystemView does not know falls through its `case _`, so nothing is
735+
* written and this is then just getOrCreateSystemView.
736+
*
737+
* Only permissions. Unlike factoryResetSystemView this leaves name, description, isPublic_ and
738+
* the alias flags alone, so an operator's edits to those survive an upgrade.
739+
*/
740+
def ensureSystemViewUpToDate(viewId: String) : Box[View] = {
741+
for {
742+
_ <- getOrCreateSystemView(viewId)
743+
entity <- ViewDefinition.findSystemView(viewId) ?~! s"$SystemViewNotFound $viewId"
744+
} yield {
745+
val before = entity.allowed_actions.toSet
746+
applyDefaultsForSystemView(entity, viewId)
747+
val saved = entity.saveMe()
748+
val after = saved.allowed_actions.toSet
749+
if (after != before) {
750+
logger.warn(
751+
s"ensureSystemViewUpToDate: system view $viewId carried ${before.size} permission(s) " +
752+
s"but the code defines ${after.size}. Brought into line. " +
753+
s"Removed: ${(before -- after).toList.sorted.mkString(", ")}. " +
754+
s"Added: ${(after -- before).toList.sorted.mkString(", ")}.")
755+
}
756+
saved
757+
}
758+
}
759+
720760
def getOrCreateSystemView(viewId: String) : Box[View] = {
721761
getExistingSystemView(viewId) match {
722762
case Empty =>

‎obp-api/src/main/scala/code/views/Views.scala‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ trait Views {
119119
*/
120120
def factoryResetSystemView(viewId: ViewId) : Box[View]
121121

122+
/**
123+
* Get or create a system view AND bring its permissions into line with the code.
124+
*
125+
* getOrCreateSystemView returns an existing row untouched, so a view created by an older
126+
* version keeps whatever permission set that version gave it -- a change to a view's can_*
127+
* set in code therefore reaches new installations only. This is what boot calls instead, so
128+
* the code stays the source of truth for the views it defines on every installation.
129+
*
130+
* Only the permission set is reconciled. Unlike factoryResetSystemView this leaves the row's
131+
* name, description and flags alone, so an operator's edits to those survive.
132+
*/
133+
def ensureSystemViewUpToDate(viewId: String) : Box[View]
134+
122135
def getOwners(view: View): Set[User]
123136

124137
def removeAllAccountAccess(bankId: BankId, accountId: AccountId) : Boolean

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,89 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{
129129
actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should equal(Set(Constant.CAN_SEE_BANK_ACCOUNT_BALANCE, Constant.CAN_QUERY_AVAILABLE_FUNDS))
130130
}
131131

132+
/**
133+
* The scenario above proves the permission sets are right when a view is *created*. That is
134+
* the only path it can reach: afterEach drops every ViewDefinition, so every scenario starts
135+
* on an empty table and getOrCreateSystemView always takes its create branch.
136+
*
137+
* The upgrade path is the one that matters and was never covered. applyDefaultsForSystemView
138+
* runs from unsavedSystemView (creation) and factoryResetSystemView (an admin endpoint);
139+
* getOrCreateSystemView -- what boot calls -- returns an existing row untouched. So on a
140+
* database where these views already exist carrying the generic set an older version gave
141+
* them, tightening the sets in code changes nothing at all: "Detail" still grants nothing
142+
* beyond "Basic", and Balances alone still exposes transaction and counterparty data.
143+
*
144+
* These build that database and then do what boot does.
145+
*/
146+
val upgradedViews = List(
147+
Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID -> Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION,
148+
Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID -> Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION,
149+
Constant.SYSTEM_READ_BALANCES_VIEW_ID -> Constant.SYSTEM_READ_BALANCES_VIEW_PERMISSION,
150+
Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID -> Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION,
151+
Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID -> Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION,
152+
Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID -> Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION,
153+
Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID -> Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION,
154+
Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID -> Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION,
155+
Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID -> Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION
156+
)
157+
158+
/** A database written by the version that gave all of these one generic permission set. */
159+
def seedPreUpgradeDatabase(): Unit = upgradedViews.foreach { case (viewId, _) =>
160+
val view = MapperViews.getOrCreateSystemView(viewId)
161+
.openOrThrowException(s"$viewId should be a known system view")
162+
ViewPermission.resetViewPermissions(view, Constant.SYSTEM_VIEW_PERMISSION_COMMON)
163+
}
164+
165+
def permissionsOf(viewId: String): Set[String] =
166+
ViewDefinition.findSystemView(viewId)
167+
.openOrThrowException(s"$viewId should exist by now").allowed_actions.toSet
168+
169+
scenario("an upgrade brings existing system views into line with the code") {
170+
Given("a database whose nine UK/BG views carry the old generic permission set")
171+
seedPreUpgradeDatabase()
172+
permissionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should
173+
equal(Constant.SYSTEM_VIEW_PERMISSION_COMMON.toSet)
174+
175+
When("boot sets the system views up")
176+
upgradedViews.foreach { case (viewId, _) => MapperViews.ensureSystemViewUpToDate(viewId) }
177+
178+
Then("every one of them matches what the code defines")
179+
upgradedViews.foreach { case (viewId, expected) =>
180+
withClue(s"$viewId: ") { permissionsOf(viewId) should equal(expected.toSet) }
181+
}
182+
183+
And("Balances in particular no longer carries transaction or counterparty visibility")
184+
val balances = permissionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID)
185+
balances.filter(_.contains("transaction")) shouldBe empty
186+
balances.filter(_.contains("other_account")) shouldBe empty
187+
}
188+
189+
scenario("reconciling twice is a no-op, not a second write") {
190+
seedPreUpgradeDatabase()
191+
upgradedViews.foreach { case (viewId, _) => MapperViews.ensureSystemViewUpToDate(viewId) }
192+
val afterFirst = upgradedViews.map { case (viewId, _) => viewId -> permissionsOf(viewId) }.toMap
193+
194+
When("boot runs again, as it does on every restart")
195+
upgradedViews.foreach { case (viewId, _) => MapperViews.ensureSystemViewUpToDate(viewId) }
196+
197+
Then("nothing has changed, and no duplicate rows have accumulated")
198+
upgradedViews.foreach { case (viewId, _) =>
199+
withClue(s"$viewId: ") {
200+
permissionsOf(viewId) should equal(afterFirst(viewId))
201+
val rows = ViewPermission.findSystemViewPermissions(ViewId(viewId))
202+
rows.map(_.permission.get).distinct.size should equal(rows.size)
203+
}
204+
}
205+
}
206+
207+
scenario("a view the code does not define is left alone") {
208+
val owner = MapperViews.getOrCreateSystemView(Constant.SYSTEM_OWNER_VIEW_ID)
209+
.openOrThrowException("owner should be a known system view")
210+
val before = owner.allowed_actions.toSet
211+
MapperViews.ensureSystemViewUpToDate(Constant.SYSTEM_READ_BALANCES_VIEW_ID)
212+
permissionsOf(Constant.SYSTEM_OWNER_VIEW_ID) should equal(before)
213+
}
214+
132215
}
133216

134217

0 commit comments

Comments
 (0)