Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
24caf7c
Glossary item for Sandbox Populator
simonredfern Oct 16, 2026
c774b1c
fix: return 400 for malformed ETH_SEND_RAW_TRANSACTION params —
simonredfern Oct 16, 2026
4193f77
fix: pass the CallContext into createTransactionRequest instead of
simonredfern Oct 17, 2026
9c15ac9
test: tag the real-payment Cardano scenarios RequiresCardanoWallet and
simonredfern Oct 17, 2026
0a750af
Get chat message checks the message is in the room
simonredfern Oct 18, 2026
c68bfdb
Fix: Update Customer Address checks the bank and customer
simonredfern Oct 18, 2026
82075b0
Fix: Get Customer by CUSTOMER_ID checks the customer's bank
simonredfern Oct 18, 2026
e2b9d5e
Fix: Update Account Attribute checks the attribute's bank
simonredfern Oct 18, 2026
45b0555
Fix: Get account balances v5.1.0 needs a balance view
simonredfern Oct 18, 2026
00d1bb2
Fix: Create Bank refuses an existing bank id in v2.2.0 and v4.0.0
simonredfern Oct 18, 2026
94d5fd8
Fix: Don't allow v4.0.0 to update an existing bank.
simonredfern Oct 18, 2026
6476ed8
Fix: Add My Api Collection Endpoint By Id checks the owner
simonredfern Oct 19, 2026
7f11790
Fix: Make sure Delete My Api Collection only deletes a collection own…
simonredfern Oct 19, 2026
3ad6ec8
Fix: Delete My Api Collection Endpoint By Id only removes endpoints f…
simonredfern Oct 19, 2026
ea3f860
Fix: The six ATM field updates require canUpdateAtm or canUpdateAtmAt…
simonredfern Oct 19, 2026
c68bff9
Fix: Update My Personal User Attribute only changes one of the caller…
simonredfern Oct 19, 2026
02a6833
Fix: Make sure that update Consent Status only reads and changes a co…
simonredfern Oct 19, 2026
51bfd5b
Fix: Make sure Delete Chat Message only deletes a message of the room…
simonredfern Oct 19, 2026
9a7f201
Fix: Make sure that revoke access to all Views on Account revokes every
simonredfern Oct 19, 2026
d70ae50
Fix: Make sure create and Update Card Attribute need a card Role at t…
simonredfern Oct 19, 2026
de2e7b5
fix: any view, the owner view included, can now be revoked from any
simonredfern Oct 19, 2026
c0b70c4
Fix: Make sure that Update My Api Collection only changes a collectio…
simonredfern Oct 19, 2026
8b6da33
Fix: Make sure that the data warehouse search no longer writes query …
simonredfern Oct 19, 2026
5b140c3
Fix: Make sure that Update Product Attribute v3.1.0 only changes an a…
simonredfern Oct 19, 2026
92bbd30
Fix: Make sure that Delete My Api Collection Endpoint By Id only remo…
simonredfern Oct 19, 2026
48b4f8e
Fix: Make sure that Create Fx writes the FX rate at the bank in the p…
simonredfern Oct 19, 2026
94c715c
Fix: Make sure that Create Customer Address only adds an address to a…
simonredfern Oct 19, 2026
bfee1b0
Fix: Make sure that Delete Product Attribute only deletes an attribut…
simonredfern Oct 19, 2026
6a3f9be
Fix: Make sure that Create Tax Residence only adds a tax residence to…
simonredfern Oct 19, 2026
d4ce3d1
Fix: Make sure that Delete Tax Residence only deletes a tax residence…
simonredfern Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 13 additions & 27 deletions docs/operations/CONSENT_STALE_ACCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,30 +43,15 @@ stale row. Treat the WARN as live over-exposure, not as a cosmetic inconsistency

## Why the revoke was refused

`revokeAccess` ([`MapperViews.scala:270`](../../obp-api/src/main/scala/code/views/MapperViews.scala#L270))
delegates to `canRevokeOwnerAccess`
([`MapperViews.scala:405`](../../obp-api/src/main/scala/code/views/MapperViews.scala#L405)), which
refuses in exactly two cases, and **only for the `owner` view**:
Until 2026-10-19 `MapperViews` had a rule, `canRevokeOwnerAccess`, that refused to revoke the `owner`
view from an account holder, or from the only user holding `owner` on the account. That rule produced
the `Failure(access cannot be revoked)` in the example above. It has been removed: every revoke path,
the consent reconciliation included, now removes any view, `owner` included.

1. the user is an account holder on that account (`MapperAccountHolders`), or
2. that `AccountAccess` row is the **only** `owner` row on the account —
`findAllByBankIdAccountIdViewId(...).length > 1` is false

Any other view id returns `true` unconditionally, so a WARN naming a view other than `owner` means
something outside these two rules failed — read the `Failure(...)` text at the end of the line
rather than following this runbook.

### Case 2 is a signal about the account, not just the consent

A shadow user is never an account holder, so in practice you are looking at case 2. And case 2 says
something stronger than "this consent is stuck":

> **Nobody else holds `owner` on that account — not even the PSU.**

A healthy account has the PSU's own `owner` row, which makes the count 2 and lets the revoke
succeed. If a consent's shadow user is the *sole* `owner` holder, the account has lost its real
owner access. **Investigate that before deleting anything**, or you will clear the symptom and leave
an account nobody owns.
On a build with that change, this WARN means something else failed. Read the `Failure(...)` text at
the end of the line (for example `CannotFindAccountAccess`) rather than following the rest of this
runbook. On an older build, the rule above is the likely cause, and the diagnosis and resolution below
apply.

## Diagnosis

Expand All @@ -80,8 +65,9 @@ JOIN resourceuser ru ON ru.id = aa.user_fk
WHERE ru.userid_ = '<user id from the log>'
AND aa.bank_id = '<bank>' AND aa.account_id = '<account>' AND aa.view_id = 'owner';

-- 2. Who else holds owner on this account? An empty result besides row 1 is case 2,
-- and is the finding that matters.
-- 2. Who else holds owner on this account? An empty result besides row 1 means the shadow user
-- was the only owner, which the old rule refused to revoke, and means the account has lost its
-- real owner access: investigate that before deleting anything.
SELECT aa.id, ru.userid_, ru.createdbyconsentid, aa.consumer_id
FROM accountaccess aa
JOIN resourceuser ru ON ru.id = aa.user_fk
Expand Down Expand Up @@ -144,8 +130,8 @@ for revoked consent 84d5a583-…. The access outlives the consent: Failure(acces

`revokeConsentAccountAccess`
([`ConsentUtil.scala:920`](../../obp-api/src/main/scala/code/api/util/ConsentUtil.scala#L920)) goes
through `revokeAccessToViewForUserAndConsumer`, which applies the same `canRevokeOwnerAccess` rule,
so it can be refused for exactly the reasons above.
through `revokeAccessToViewForUserAndConsumer`, which applied the same `canRevokeOwnerAccess` rule on
builds before 2026-10-19, so it could be refused for exactly the reasons above.

**Treat this as higher priority than the `grantAccessToViews` line.** In that case a live consent is
over-serving, and revoking the consent would still clean up. Here the consent is *already gone* and
Expand Down
6 changes: 4 additions & 2 deletions obp-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
command-line -Dtest.tagsToExclude= override actually takes effect: Maven only lets
-D override a POM-declared value when the config element is a ${property}
expression, not a fixed string literal. Default excludes PropGatedPublicEndpoint
(see run_tests_parallel.sh / the CI job that overrides this to include it). -->
<test.tagsToExclude>code.external,GetBanksPerf,PropGatedPublicEndpoint</test.tagsToExclude>
(see run_tests_parallel.sh / the CI job that overrides this to include it), and
RequiresCardanoWallet, whose scenarios send real payments through a cardano-wallet
server that CI does not have (see CardanoTransactionRequestTest). -->
<test.tagsToExclude>code.external,GetBanksPerf,PropGatedPublicEndpoint,RequiresCardanoWallet</test.tagsToExclude>
</properties>

<dependencies>
Expand Down
98 changes: 1 addition & 97 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1823,7 +1823,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
* runtime-compiled dynamic-endpoint dispatch. Runs the SAME ordered checks — authentication,
* obp-id format, bank, roles, account, view, counterparty — with the same predicates and *Fun
* helpers, returning the enriched CallContext (user set) for a native handler instead of
* wrapping a Lift OBPEndpoint. No S.init / SS / Box[JsonResponse]; the compiled native body
* wrapping a Lift OBPEndpoint. No S.init / Box[JsonResponse]; the compiled native body
* looks up bank/account/view itself, so the entities validated here serve only 404/403 gating,
* exactly as the Lift checks did. Auth/role/lookup failures fail the Future (the dispatcher
* converts them to a response via ErrorResponseConverter).
Expand Down Expand Up @@ -1891,102 +1891,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
}else operationId
}

/**
* Simulate S pass request and session, this object pass bank, account and view.
* This method invoke must fulfill three point:
* 1. be invoked at endpoint's body
* 2. be invoked out of for comprehension
* 3. endpoint's corresponding ResourceDoc.errorResponseBodies must contains $BankNotFound, $BankAccountNotFound or $UserNoPermissionAccessView
*/
object SS {
private val _user = new ThreadGlobal[User]
private val _bank = new ThreadGlobal[Bank]
private val _bankAccount = new ThreadGlobal[BankAccount]
private val _view = new ThreadGlobal[View]
private val _callContext = new ThreadGlobal[Option[CallContext]]

def init[B](boxUser: Box[User], bank: Bank, bankAccount: BankAccount, view: View, callContext: Option[CallContext])(f: => B):B = {
_user.doWith(boxUser.orNull){
_bank.doWith(bank) {
_bankAccount.doWith(bankAccount) {
_view.doWith(view) {
_callContext.doWith(callContext){
f
}
}
}
}
}
}

private def bank: Bank = _bank.box.openOrThrowException(buildErrorMsg(nameOf($BankNotFound)))
private def bankAccount: BankAccount = _bankAccount.box.openOrThrowException(buildErrorMsg(nameOf($BankAccountNotFound)))
private def getView: View = _view.box.openOrThrowException(buildErrorMsg(nameOf($UserNoPermissionAccessView)))
private def callContext: Option[CallContext] = _callContext.box.orNull

/**
* Get current login user, recommend call cc.loggedInUser instead.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
* @return current login user
*/
def user: Future[(Box[User], Option[CallContext])] = Future.successful(_user.box -> callContext)

/**
* Get current login user, and bank find by url /BANK_ID/.
* Validation of id exists passed.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
*/
def userBank: Future[(Box[User], Bank, Option[CallContext])] = Future.successful((_user.box, bank, callContext))

def userBankView: Future[(Box[User], Bank, View, Option[CallContext])] = Future.successful((_user.box, bank, getView, callContext))

/**
* Get current login user and bank find by url /BANK_ID/ and account find by /ACCOUNT_ID/.
* Validation of id exists passed.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
*/
def userBankAccount: Future[(Box[User], Bank, BankAccount, Option[CallContext])] = Future.successful {
(_user.box, bank, bankAccount, callContext)
}

/**
* Get current login user, and bank find by url /BANK_ID/, and account find by /ACCOUNT_ID/, and view find by /VIEW_ID/.
* Validation of id exists passed.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
*/
def userBankAccountView: Future[(Box[User], Bank, BankAccount, View, Option[CallContext])] = Future.successful {
(_user.box, bank, bankAccount, getView, callContext)
}

/**
* Get view find by /VIEW_ID/.
* Validation of id exists passed.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
*/
def view: Future[(View, Option[CallContext])] = Future.successful(getView -> callContext)
/**
* Get current login user, and view find by /VIEW_ID/.
* Validation of id exists passed.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
*/
def userView: Future[(Box[User], View, Option[CallContext])] = Future.successful((_user.box, getView, callContext))

/**
* Get current login user and account find by /ACCOUNT_ID/.
* Validation of id exists passed.
* Note, the same as S.session of lift framework, the method only can be called at first line of endpoint for comprehension or out of for comprehension.
*/
def userAccount: Future[(Box[User], BankAccount, Option[CallContext])] = Future.successful((_user.box, bankAccount, callContext))

private def buildErrorMsg(msg: String) =
s"""
|This method invoke must fulfill three point:
| 1. be invoked at endpoint's body
| 2. be invoked out of for comprehension
| 3. endpoint's corresponding ResourceDoc.errorResponseBodies must contains $msg
|""".stripMargin
}

/**
* The Glossary as served by GET /api/glossary: the static Glossary Items compiled into
* Glossary.scala, unioned with the Dynamic Glossary Items held in the database. A Dynamic Item
Expand Down
10 changes: 3 additions & 7 deletions obp-api/src/main/scala/code/api/util/ConsentUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -535,10 +535,6 @@ object Consent extends MdcLoggable {
// no way back short of deleting rows by hand. The narrower reading is that the consent still
// holds everything it declares, plus one view it should have lost; refusing everything is a
// worse answer to that than serving it and saying so loudly.
//
// canRevokeOwnerAccess is the realistic trigger (MapperViews): it refuses to drop an `owner`
// row when no other principal holds one on that account, which an OBP-native consent can reach
// because createConsentJWT takes its views from whatever the PSU already holds, `owner` included.
for {
staleAccess <- held.filterNot(wantedSet.contains)
} yield {
Expand Down Expand Up @@ -1031,9 +1027,9 @@ object Consent extends MdcLoggable {
if (count > 0) {
logger.info(s"revokeConsentAccountAccess: dropped $count account access rows for consent ${consent.consentId}")
}
// revokeAccessToViewForUserAndConsumer can refuse -- canRevokeOwnerAccess will not drop the
// last `owner` row on an account -- so counting the attempts rather than the successes
// reported rows as gone while they were still there. On a revoked consent that is the worst
// revokeAccessToViewForUserAndConsumer can fail (for example when the row is not found), so
// counting the attempts rather than the successes would report rows as gone while they were
// still there. On a revoked consent that is the worst
// version of this to get wrong: the access outlives the consent entirely, and nothing else
// ever comes back for it.
for ((access, outcome) <- stuck) {
Expand Down
6 changes: 6 additions & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ object ErrorMessages {
val UserAlreadyValidated = "OBP-20107: User email is already validated."
val InvalidRedirectUrl = "OBP-20108: Invalid redirect URL. Each redirect URL must use https, use http only for localhost, 127.0.0.1 or [::1], or use an app scheme in reverse-domain form such as com.example.app, and must not contain a wildcard, user information or a fragment. Rejected entry: "
val RedirectUrlHostNotAllowed = "OBP-20109: Redirect URL host not allowed. This instance only accepts redirect URLs that point to the hosts listed for it. Rejected entry: "
// OBP-20110 (OwnerViewAccessMustRemain) is retired: account holders and the last owner can now lose their owner view access.
val CannotRevokeOwnAccountAccess = "OBP-20111: Cannot revoke your own account access. A user cannot revoke their own access to the views of an account; another user with permission to revoke access must do it."

// OAuth 2
val ApplicationNotIdentified = "OBP-20200: The application cannot be identified. "
Expand Down Expand Up @@ -501,6 +503,10 @@ object ErrorMessages {
val InvestigationReportNotAvailable = "OBP-30150: Investigation Report is only available in mapped mode (connector=mapped)."
val NotificationWebhookNotFound = "OBP-30151: Account Notification Webhook not found. Please specify a valid value for WEBHOOK_ID."
val DeleteWebhookError = "OBP-30152: Could not delete the Webhook."
// an account attribute that does not exist, or does not belong to the BANK_ID / ACCOUNT_ID / PRODUCT_CODE in the path
val AccountAttributeNotFound = "OBP-30153: Account Attribute not found. Please specify a valid value for ACCOUNT_ATTRIBUTE_ID for this BANK_ID, ACCOUNT_ID and PRODUCT_CODE."
// a product attribute that does not exist, or does not belong to the BANK_ID / PRODUCT_CODE in the path
val ProductAttributeNotFound = "OBP-30154: Product Attribute not found. Please specify a valid value for PRODUCT_ATTRIBUTE_ID for this BANK_ID and PRODUCT_CODE."

val CreateWebhookError = "OBP-30047: Cannot create Webhook"
val GetWebhooksError = "OBP-30048: Cannot get Webhooks"
Expand Down
38 changes: 38 additions & 0 deletions obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3195,6 +3195,44 @@ object Glossary extends MdcLoggable {
${getWebUiPropsValue("webui_sandbox_introduction", "")}
""")

glossaryItems += GlossaryItem(
title = "Sandbox Populator",
description =
s"""|
|The OBP Sandbox Populator is used to create bank level data for a logged in developer that needs test data.
|
|The User logs in to it via OAuth2, and it calls OBP endpoints on behalf of that User. Everything it creates is therefore normal OBP data that can be explored afterwards with the API Explorer, API Manager or any REST client.
|
|### What it creates
|
|* Banks
|* Accounts (with Customer Account Links)
|* Customers (individual and corporate), Customer Links and User Customer Links
|* Counterparties
|* FX Rates
|* Transactions (via Transaction Requests or historical Transactions)
|
|Data sets are available for several countries (e.g. Botswana and Singapore) which determine the business names, customer templates, currencies and transaction patterns used.
|
|### How to use it
|
|* **Populate:** choose the number of Banks, the number of Accounts per Bank, a country, and which entities to create, then run. The results link to the API Explorer for verification.
|* **CSV Import:** upload CSV files of Banks, Accounts, Customers and Transactions to create them in bulk.
|
|### Roles
|
|The logged in User needs the relevant Entitlements for the data being created, for instance CanCreateCustomer, CanCreateAccount, CanCreateCustomerAccountLink, CanCreateUserCustomerLink and CanCreateHistoricalTransactionAtBank. OBP error messages (including missing Role errors) are shown as returned by the API.
|
|### Running it
|
|The Sandbox Populator is a SvelteKit application that uses Redis for session storage. It can be run with Docker Compose alongside a local OBP API and Redis, or locally with Node.
|
|Source code and documentation: [OBP Sandbox Populator](https://github.com/OpenBankProject/OBP-Sandbox-Populator)
|
|See also: ${getGlossaryItemLink("Sandbox Introduction")}
|
""")


glossaryItems += GlossaryItem(
title = "Data Model Overview",
Expand Down
14 changes: 12 additions & 2 deletions obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,9 @@ object Http4s121 {
for {
(_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc))
(account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext)
_ <- Helper.booleanToFuture(CannotRevokeOwnAccountAccess, 403, cc = callContext) {
!(user.provider == provider && user.idGivenByProvider == providerId)
}
_ <- ViewNewStyle.revokeAccessToView(account, user, BankIdAccountIdViewId(BankId(bankId), AccountId(accountId), ViewId(viewId)), provider, providerId, callContext)
} yield ()
}
Expand All @@ -912,13 +915,15 @@ object Http4s121 {
"/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID/views/VIEW_ID",
"Revoke access to one View",
s"""Revokes access to a View on an Account for a certain User.
|
|Any View can be revoked, the owner view included, also from an account holder. A User cannot revoke their own access: that request is refused with $CannotRevokeOwnAccountAccess
|
|$generalRevokeAccessToViewText
|
|${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""",
EmptyBody,
EmptyBody,
List(AuthenticatedUserIsRequired, BankAccountNotFound, "could not save the privilege", "user does not have access to owner view on account", UnknownError),
List(AuthenticatedUserIsRequired, BankAccountNotFound, CannotRevokeOwnAccountAccess, "could not save the privilege", "user does not have access to owner view on account", UnknownError),
List(apiTagView, apiTagAccount, apiTagUser, apiTagEntitlement, apiTagOwnerRequired),
http4sPartialFunction = Some(removePermissionForUserForBankAccountForOneView)
)
Expand All @@ -931,6 +936,9 @@ object Http4s121 {
for {
(_, callContext) <- NewStyle.function.getBank(BankId(bankId), Some(cc))
(account, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), callContext)
_ <- Helper.booleanToFuture(CannotRevokeOwnAccountAccess, 403, cc = callContext) {
!(user.provider == provider && user.idGivenByProvider == providerId)
}
_ <- NewStyle.function.revokeAllAccountAccess(account, user, provider, providerId, callContext)
} yield ()
}
Expand All @@ -943,13 +951,15 @@ object Http4s121 {
"/banks/BANK_ID/accounts/ACCOUNT_ID/permissions/PROVIDER/PROVIDER_ID/views",
"Revoke access to all Views on Account",
s""""Revokes access to all Views on an Account for a certain User.
|
|Every View is revoked, the owner view included, also from an account holder. A User cannot revoke their own access: that request is refused with $CannotRevokeOwnAccountAccess
|
|$generalRevokeAccessToViewText
|
|${userAuthenticationMessage(true)} and the user needs to have access to the owner view.""",
EmptyBody,
EmptyBody,
List(AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError, "user does not have access to owner view on account"),
List(AuthenticatedUserIsRequired, BankAccountNotFound, CannotRevokeOwnAccountAccess, UnknownError, "user does not have access to owner view on account"),
List(apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired),
http4sPartialFunction = Some(removePermissionForUserForBankAccountForAllViews)
)
Expand Down
Loading
Loading