diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index f755552630..b7e11aae4e 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -1159,6 +1159,7 @@ object ToSchemify extends MdcLoggable {
code.glossaryitem.DynamicGlossaryItem,
code.platformapp.PlatformApp,
code.platformapp.PlatformAppRequiredScope,
+ code.consumeraccountaccess.ConsumerAccountAccess,
code.domainapi.DomainApi,
code.dynamicentitywebhook.DynamicEntityWebhook,
code.accountcreation.AccountCreation,
diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala
index a90f6bcb39..87fb6da24b 100644
--- a/obp-api/src/main/scala/code/api/OAuth2.scala
+++ b/obp-api/src/main/scala/code/api/OAuth2.scala
@@ -400,6 +400,11 @@ object OAuth2Login extends MdcLoggable {
* }
* @return an existing or a new user
*/
+ // TODO Record the user's agreement to the Terms and Conditions and the Privacy Policy for users created here (first
+ // sign-in through Keycloak or another OIDC provider), as AuthUser's own sign-up does: a UserAgreement each for
+ // terms_and_conditions and privacy_conditions holding the full text of webui_terms_and_conditions and
+ // webui_privacy_policy at that moment (with its date and hash). Today only AuthUser.actionsAfterSignup records them, so users
+ // from an external identity provider have none. The same applies to getOrCreateResourceUser below.
def getOrCreateResourceUserFuture(jwtToken: String): Future[Box[User]] = {
val uniqueIdGivenByProvider = JwtUtil.getSubject(jwtToken).getOrElse("")
val provider = resolveProvider(jwtToken)
diff --git a/obp-api/src/main/scala/code/api/cache/Redis.scala b/obp-api/src/main/scala/code/api/cache/Redis.scala
index 8cba91faa3..d1588b3464 100644
--- a/obp-api/src/main/scala/code/api/cache/Redis.scala
+++ b/obp-api/src/main/scala/code/api/cache/Redis.scala
@@ -321,23 +321,29 @@ object Redis extends MdcLoggable {
* (refused without reading the counter's value), > 0 max calls in the period
* ARGV[n + i] length of period i in seconds, the TTL a new counter gets
*
- * Returns {0, i, count, ttl} when period i refuses: count is what its counter holds now (0 when
- * the key is absent or has no expiry) and ttl is the key's TTL as Redis reports it. Returns
- * {1, c1, ttl1, ..., cn, ttln} after counting: the post-increment count and TTL of each key.
- * A key found without an expiry is treated as expired on the check and recreated on the count,
- * as incrementWithTtlScript does. Everything runs atomically server-side, so the check and
- * the count of one call cannot interleave with another call's: at most `limit` calls pass.
+ * Returns {0, i, count, pttl} when period i refuses: count is what its counter holds now (0 when
+ * the key is absent or has no expiry) and pttl is the key's PTTL in milliseconds as Redis reports
+ * it (-1 no expiry, -2 no key). Returns {1, c1, pttl1, ..., cn, pttln} after counting: the
+ * post-increment count and PTTL of each key. A key found without an expiry is treated as expired
+ * on the check and recreated on the count, as incrementWithTtlScript does. Everything runs
+ * atomically server-side, so the check and the count of one call cannot interleave with another
+ * call's: at most `limit` calls pass.
+ *
+ * Liveness is decided on PTTL, not TTL. TTL rounds to the nearest second, so a 1-second counter
+ * reads TTL 0 for the last ~500 ms of its window while it still holds its count; a check on TTL
+ * treated it as expired there and let every call through, and the per-second limit held for only
+ * half of each second.
*/
private val quotaCheckAndCountScript =
"""local n = #KEYS
|for i = 1, n do
| local limit = tonumber(ARGV[i])
| if limit >= 0 then
- | local ttl = redis.call('TTL', KEYS[i])
+ | local pttl = redis.call('PTTL', KEYS[i])
| local count = 0
- | if ttl > 0 then count = tonumber(redis.call('GET', KEYS[i]) or '0') end
+ | if pttl > 0 then count = tonumber(redis.call('GET', KEYS[i]) or '0') end
| if limit == 0 or count + 1 > limit then
- | return {0, i, count, ttl}
+ | return {0, i, count, pttl}
| end
| end
|end
@@ -346,12 +352,12 @@ object Redis extends MdcLoggable {
| local c = redis.call('INCR', KEYS[i])
| if c == 1 then
| redis.call('EXPIRE', KEYS[i], ARGV[n + i])
- | elseif redis.call('TTL', KEYS[i]) < 0 then
+ | elseif redis.call('PTTL', KEYS[i]) < 0 then
| redis.call('SET', KEYS[i], 1, 'EX', ARGV[n + i])
| c = 1
| end
| out[#out + 1] = c
- | out[#out + 1] = redis.call('TTL', KEYS[i])
+ | out[#out + 1] = redis.call('PTTL', KEYS[i])
|end
|return out""".stripMargin
@@ -361,6 +367,10 @@ object Redis extends MdcLoggable {
* shortest period first. Throws when Redis is unreachable; the caller decides whether to fail
* open.
*/
+ /** A PTTL in milliseconds as whole seconds, rounded up, so a live key never reads 0 (the
+ * X-Rate-Limit-Reset header and the TTL the callers compare with 0). -1 and -2 stay as they are. */
+ private[cache] def pttlToSeconds(pttl: Long): Long = if (pttl > 0) (pttl + 999) / 1000 else pttl
+
def quotaCheckAndCount(keys: List[String], limits: List[Long], periodSeconds: List[Int]): QuotaDecision = {
require(keys.nonEmpty && keys.size == limits.size && keys.size == periodSeconds.size,
s"quotaCheckAndCount needs one limit and one period per key: ${keys.size} keys, ${limits.size} limits, ${periodSeconds.size} periods")
@@ -376,9 +386,9 @@ object Redis extends MdcLoggable {
).asInstanceOf[java.util.List[java.lang.Long]].asScala.map(_.longValue()).toList
}
val decision = reply match {
- case 0L :: index :: count :: ttl :: Nil => QuotaRefused(index.toInt - 1, count, ttl)
- case 1L :: countsAndTtls if countsAndTtls.size == keys.size * 2 =>
- QuotaCounted(countsAndTtls.grouped(2).collect { case List(count, ttl) => (count, ttl) }.toList)
+ case 0L :: index :: count :: pttl :: Nil => QuotaRefused(index.toInt - 1, count, pttlToSeconds(pttl))
+ case 1L :: countsAndPttls if countsAndPttls.size == keys.size * 2 =>
+ QuotaCounted(countsAndPttls.grouped(2).collect { case List(count, pttl) => (count, pttlToSeconds(pttl)) }.toList)
case other => throw new RuntimeException(s"quotaCheckAndCount: unexpected reply $other for ${keys.size} keys")
}
succeeded = true
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 59059e4a52..a723d2b695 100644
--- a/obp-api/src/main/scala/code/api/util/APIUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala
@@ -3651,6 +3651,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
case Some(u) if hasAccountFirehoseAccessAtBank(view,u, bankIdAccountId.bankId) => Full(true) //Login User and Firehose access
case Some(u) if hasAccountFirehoseAccess(view,u) => Full(true)//Login User and Firehose access
case Some(u) if u.hasAccountAccess(view, bankIdAccountId, callContext)=> Full(true) // Login User and check view access
+ case Some(u) if hasConsumerAccountAccess(view, bankIdAccountId, u, callContext) => Full(true) // An application acting as itself, on its own grant
case Some(u) =>
// Normal checks failed — try ABAC as fallback
checkAbacAccountAccess(u, view, bankIdAccountId, callContext)
@@ -3659,6 +3660,28 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
}
}
+ /**
+ * Account access granted to a Consumer (code.consumeraccountaccess): true when the caller is an application
+ * acting as itself, its Consumer holds a grant for this view of this account, and the endpoint opts in.
+ *
+ * - Acting as itself means the caller is the Consumer's own client-credentials User (provider obp-oidc#client,
+ * named after the Consumer's key). A person calling through the same Consumer never picks up its grants,
+ * and neither does a caller identified by a Consumer Key alone: the token proves the client secret.
+ * - The endpoint opts in through its auth mode: only ApplicationOnly and UserXORApplication endpoints admit
+ * an application acting as itself, so every other endpoint of the view keeps needing a User's access.
+ */
+ def hasConsumerAccountAccess(view: View, bankIdAccountId: BankIdAccountId, user: User, callContext: Option[CallContext]): Boolean =
+ (for {
+ cc <- callContext
+ doc <- cc.resourceDocument
+ if doc.authMode == ApplicationOnly || doc.authMode == UserXORApplication
+ consumer <- cc.consumer.toOption
+ if isApplicationsOwnUser(user, consumer)
+ } yield code.consumeraccountaccess.ConsumerAccountAccesses.consumerAccountAccessProvider.vend
+ .find(consumer.consumerId.get, bankIdAccountId.bankId, bankIdAccountId.accountId, view.viewId)
+ .isDefined
+ ).getOrElse(false)
+
private def checkAbacAccountAccess(
user: User,
view: View,
diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
index 3fe6c392ba..75ad25007c 100644
--- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
+++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
@@ -651,6 +651,9 @@ object ErrorMessages {
val RetentionHoldAlreadyReleased = "OBP-30621: This retention hold has already been released."
val InvalidRetentionRunApproval = "OBP-30622: Invalid approval. decision must be approve or reject."
val InvalidRetentionRun = "OBP-30623: Invalid retention run. mode must be preview or execute."
+ val ConsumerAccountAccessAlreadyExists = "OBP-30627: This Consumer already has access to this view of this account."
+ val ConsumerAccountAccessNotFound = "OBP-30628: Consumer account access not found by CONSUMER_ACCOUNT_ACCESS_ID on this account."
+ val CannotGrantConsumerAccountAccessToView = "OBP-30629: A Consumer cannot be given access to this view. The owner view and public views are for Users: give the Consumer a view with only the permissions it needs."
// Routing Scheme registry (OBP-30514 .. OBP-30525)
val RoutingSchemeNotFound = "OBP-30514: Routing Scheme not found. Please specify a valid value for SCHEME."
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 ee56469e97..8109241978 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -7428,6 +7428,39 @@ object Glossary extends MdcLoggable {
|- [Update Current Consumer Platform App](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateCurrentConsumerPlatformApp): `PUT /obp/v7.0.0/consumers/current/platform-app`, for an app to declare the Scopes it needs.
|- [Get Current Consumer Scopes](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getCurrentConsumerScopes): `GET /obp/v7.0.0/consumers/current/scopes`, for an app to read the Scopes its Consumer holds.
|- [Create Scope for a Consumer](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-addScope): `POST /obp/v7.0.0/consumers/CONSUMER_ID/scopes`, to grant a missing Scope.
+ |
+ |A Scope says which endpoints an app may call. An app that also acts on an account through a view, such as initiating payments from it, needs access to that view of the account as well: see ${getGlossaryItemLink("Consumer Account Access")}.
+ |""".stripMargin)
+
+ glossaryItems += GlossaryItem(
+ title = "Consumer Account Access",
+ description =
+ s"""
+ |# Consumer Account Access
+ |
+ |OBP has two layers of permission. **Roles** say which endpoints may be called; they are granted to a User as Entitlements and to a Consumer as Scopes. **Account access** says which accounts may be used, and through which view; it is granted to a User, and with Consumer Account Access also to a Consumer.
+ |
+ |It is for an application that acts on an account as itself, with its own application token (OAuth2 client credentials), when no person is involved: for example a bank's service that initiates payments from the bank's own account for customers whose payments it relays. Without it, such a service has to log in as a stand-in User who holds the account access.
+ |
+ |## How it works
+ |
+ |1. Someone who may grant views of the account (their view lists the target view in `can_grant_access_to_views`, or has `can_grant_access_to_custom_views` for a custom view) gives the Consumer access to a view. The grant records who gave it.
+ |2. The application calls an endpoint of that view with its client-credentials token. Its User is then the Consumer's own (provider `obp-oidc#client`, named after the Consumer's key), and OBP finds the Consumer's grant.
+ |3. The grant counts only on endpoints that admit an application acting as itself (auth mode ApplicationOnly or UserXORApplication). On every other endpoint the view still needs a User's account access.
+ |
+ |A person who logs in through the same Consumer does not get the Consumer's account access, and a caller identified by the Consumer Key alone does not either: only the client-credentials token proves the application is calling as itself.
+ |
+ |## Which view
+ |
+ |The owner view and public views can't be granted to a Consumer. Holding an account is for people. Give the Consumer a view with only the permissions it needs, for example a custom view with `can_add_transaction_request_to_any_account` for initiating payments: create it with Create Custom View (Management), `POST /obp/v6.0.0/management/banks/BANK_ID/accounts/ACCOUNT_ID/views` (Role CanCreateCustomView), and grant it through the ManageCustomViews system view, which has `can_grant_access_to_custom_views` and which the account holder can give through owner. The Transaction Requests the Consumer creates record it.
+ |
+ |## Endpoints
+ |
+ |- [Grant Consumer access to View](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-grantConsumerAccessToView): `POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access`
+ |- [Get Consumer access to Account](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getConsumerAccountAccess): `GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access`
+ |- [Revoke Consumer access to View](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-revokeConsumerAccessToView): `DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access/CONSUMER_ACCOUNT_ACCESS_ID`
+ |
+ |See also ${getGlossaryItemLink("Platform Apps")}.
|""".stripMargin)
glossaryItems += GlossaryItem(
diff --git a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala
index 7824b99de3..6b8fb9d3c5 100644
--- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala
@@ -44,6 +44,7 @@ import redis.clients.jedis.Jedis
import scala.collection.immutable
import scala.collection.immutable.{List, Nil}
+import scala.util.control.NonFatal
object RateLimitingPeriod extends Enumeration {
@@ -296,7 +297,8 @@ object RateLimitingUtil extends MdcLoggable {
* @param periodsWithLimits the periods to check and count, shortest first, each with its limit
* @return None when nothing was checked or counted: `use_consumer_limits=false` (an instance
* without Redis is left alone) or Redis unreachable (fail open: the call is allowed and
- * no X-Rate-Limit-* headers are set)
+ * no X-Rate-Limit-* headers are set), except that a blocked period (limit 0) still
+ * refuses when Redis is unreachable, see [[refusalWithoutRedis]]
*/
private def checkAndCount(consumerKey: String, periodsWithLimits: List[(LimitCallPeriod, Long)]): Option[QuotaOutcome] =
if (!useConsumerLimits) None
@@ -313,11 +315,22 @@ object RateLimitingUtil extends MdcLoggable {
Some(Counted(periodsWithLimits.zip(countsAndTtls).map { case ((period, limit), (count, ttl)) => (period, limit, (ttl, count)) }))
}
} catch {
- case e: Throwable =>
- logger.error(s"Redis unavailable when checking the rate limit for $consumerKey - allowing request", e)
- None
+ case NonFatal(e) =>
+ val outcome = refusalWithoutRedis(periodsWithLimits)
+ // A warning without the stack trace: while Redis is down this happens on every request.
+ logger.warn(s"Redis unavailable when checking the rate limit for $consumerKey (${e.getClass.getSimpleName}: ${e.getMessage}) - " +
+ (if (outcome.isDefined) "refusing: a period is blocked (limit 0)" else "allowing request"))
+ outcome
}
+ /**
+ * What the quota decides when Redis can't be asked: a blocked period (limit 0) refuses without a
+ * counter, the shortest one first, as it always has; everything else fails open (None). Without
+ * this a Consumer an administrator had blocked would be served whenever Redis is unreachable.
+ */
+ private[util] def refusalWithoutRedis(periodsWithLimits: List[(LimitCallPeriod, Long)]): Option[QuotaOutcome] =
+ periodsWithLimits.collectFirst { case (period, 0L) => Refused(period, 0L, 0L) }
+
def consumerRateLimitState(consumerKey: String): immutable.Seq[((Option[Long], Option[Long], String), LimitCallPeriod)] = {
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 aa29f2d9e8..90157ceaf3 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
@@ -4764,6 +4764,8 @@ object Http4s700 {
|
|Requires `open_corridor_enabled=true` on this instance. Whether it is on shows in GET /banks/BANK_ID/transaction-request-types, which lists OPEN_CORRIDOR_PROMISE exactly when it is.
|
+ |The caller is either a User with account access to VIEW_ID, or an application acting as itself (OAuth2 client credentials) whose Consumer has been given access to VIEW_ID of this account (see Consumer Account Access). The view needs `can_add_transaction_request_to_any_account` or `can_add_transaction_request_to_beneficiary`. The Transaction Request records the Consumer that created it.
+ |
|Authentication is Required.""".stripMargin,
openCorridorBodyExample,
JSONFactory700.TransactionRequestWithChargeOpenCorridorJsonV700(
@@ -4800,6 +4802,7 @@ object Http4s700 {
InsufficientAuthorisationToCreateTransactionRequest, UnknownError),
apiTagTransactionRequest :: Nil,
None,
+ authMode = UserXORApplication,
http4sPartialFunction = Some(createTransactionRequestOpenCorridor)
)
@@ -7722,6 +7725,9 @@ object Http4s700 {
// Data retention: retention policies, runs and their approval, the disposal register, legal holds and the archive.
resourceDocs ++= Http4s700Retention.resourceDocs
+ // Consumer account access: a view of an account granted to a Consumer, for an application acting as itself.
+ resourceDocs ++= Http4s700ConsumerAccountAccess.resourceDocs
+
// The order in which the routes are tried. ResourceDocMiddleware selects the doc of the first
// route that serves a request, so it is given this same list: the doc that validates a request
// is the doc of the route that runs it.
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala
new file mode 100644
index 0000000000..41cbd39abb
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700ConsumerAccountAccess.scala
@@ -0,0 +1,236 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import code.api.Constant.{ApiPathZero, CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS}
+import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc, canGrantAccessToView, canRevokeAccessToView, getUserLacksGrantPermissionErrorMessage, getUserLacksRevokePermissionErrorMessage, isOwnerView, isPublicView, isValidSystemViewId, userAuthenticationMessage}
+import code.api.util.ApiTag._
+import code.api.util.ErrorMessages._
+import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers
+import code.api.util.newstyle.ViewNewStyle
+import code.api.util.{CallContext, CustomJsonFormats, Glossary, NewStyle}
+import code.consumeraccountaccess.ConsumerAccountAccesses
+import code.util.Helper
+import com.github.dwickern.macros.NameOf.nameOf
+import com.openbankproject.commons.ExecutionContext.Implicits.global
+import com.openbankproject.commons.model.{BankIdAccountId, BankIdAccountIdViewId, ViewId}
+import com.openbankproject.commons.util.ApiVersion
+import net.liftweb.common.Box
+import org.http4s._
+import org.http4s.dsl.io._
+import org.json4s.Formats
+
+import scala.collection.mutable.ArrayBuffer
+import scala.concurrent.Future
+
+/**
+ * The v7.0.0 Consumer account access endpoints: a view of an account granted to a Consumer, so that the
+ * application, acting as itself (client credentials), can use that view on the endpoints that admit an
+ * application. See [[code.consumeraccountaccess.ConsumerAccountAccesses]] and the Glossary item
+ * "Consumer Account Access".
+ */
+object Http4s700ConsumerAccountAccess {
+
+ implicit val formats: Formats = CustomJsonFormats.formats
+
+ private val implementedInApiVersion = ApiVersion.v7_0_0
+ private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
+
+ val resourceDocs = ArrayBuffer[ResourceDoc]()
+
+ private def provider = ConsumerAccountAccesses.consumerAccountAccessProvider.vend
+
+ private def fullOrFail[T: Manifest](box: Box[T], cc: CallContext): T =
+ code.api.util.APIUtil.unboxFullOrFail(box, Some(cc), UnknownError, 400)
+
+ private def callerAndView(cc: CallContext) = (
+ cc.user.openOrThrowException(AuthenticatedUserIsRequired),
+ cc.bankAccount.getOrElse(throw new RuntimeException("BankAccount not found in CallContext")),
+ cc.view.getOrElse(throw new RuntimeException("View not found in CallContext"))
+ )
+
+ private val description =
+ s"""Account access can be granted to a Consumer as well as to a User. The application then uses the view
+ |as itself, with an application token (OAuth2 client credentials), and no person is involved. A person
+ |calling through the same Consumer does not get the Consumer's access, and an application identified
+ |by its Consumer Key alone does not either.
+ |
+ |The access counts only on the endpoints that admit an application acting as itself (auth mode
+ |ApplicationOnly or UserXORApplication), such as creating an OPEN_CORRIDOR_PROMISE Transaction Request.
+ |On every other endpoint the view still needs a User's account access.
+ |
+ |Give the Consumer a view with only the permissions it needs, for example a custom view with
+ |can_add_transaction_request_to_any_account, granted through the ManageCustomViews system view. The
+ |owner view and public views can't be granted to a Consumer.
+ |
+ |For more information see ${Glossary.getGlossaryItemLink("Consumer Account Access")}""".stripMargin
+
+ // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access (201)
+ lazy val grantConsumerAccessToView: Http4sRoute = Http4sRoute {
+ case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" / _ / "consumer-account-access" =>
+ EndpointHelpers.withViewAndBodyCreated[PostConsumerAccountAccessJsonV700, ConsumerAccountAccessJsonV700](req) {
+ (user, account, view, body, cc) =>
+ val targetViewId = ViewId(Option(body.view_id).map(_.trim).getOrElse(""))
+ val bankIdAccountId = BankIdAccountId(account.bankId, account.accountId)
+ for {
+ _ <- Helper.booleanToFuture(s"$InvalidJsonFormat view_id and consumer_id are required.", failCode = 400, cc = Some(cc)) {
+ targetViewId.value.nonEmpty && Option(body.consumer_id).exists(_.trim.nonEmpty)
+ }
+ _ <- Helper.booleanToFuture(CannotGrantConsumerAccountAccessToView, failCode = 400, cc = Some(cc)) {
+ !isOwnerView(targetViewId)
+ }
+ _ <- Helper.booleanToFuture(getUserLacksGrantPermissionErrorMessage(view.viewId, targetViewId), failCode = 403, cc = Some(cc)) {
+ canGrantAccessToView(BankIdAccountIdViewId(account.bankId, account.accountId, view.viewId), targetViewId, user, Some(cc))
+ }
+ targetView <- if (isValidSystemViewId(targetViewId.value)) ViewNewStyle.systemView(targetViewId, Some(cc))
+ else ViewNewStyle.customView(targetViewId, bankIdAccountId, Some(cc))
+ _ <- Helper.booleanToFuture(CannotGrantConsumerAccountAccessToView, failCode = 400, cc = Some(cc)) {
+ !targetView.isPublic && !isPublicView(targetView)
+ }
+ consumer <- NewStyle.function.getConsumerByConsumerId(body.consumer_id.trim, Some(cc))
+ _ <- Helper.booleanToFuture(ConsumerAccountAccessAlreadyExists, failCode = 409, cc = Some(cc)) {
+ provider.find(consumer.consumerId.get, account.bankId, account.accountId, targetView.viewId).isEmpty
+ }
+ granted <- Future(provider.grant(consumer.consumerId.get, account.bankId, account.accountId, targetView.viewId, user.userId)) map {
+ x => fullOrFail(x, cc)
+ }
+ } yield JSONFactory700ConsumerAccountAccess.createConsumerAccountAccessJson(granted)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(grantConsumerAccessToView),
+ "POST",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access",
+ "Grant Consumer access to View",
+ s"""Give the Consumer identified by `consumer_id` access to the view `view_id` of this account.
+ |
+ |The caller grants it through their own view VIEW_ID, with the same permissions as granting a view to a
+ |User: for a system view, VIEW_ID must list it in `can_grant_access_to_views`; for a custom view, VIEW_ID
+ |must have `can_grant_access_to_custom_views`. The grant records who gave it.
+ |
+ |$description
+ |
+ |${userAuthenticationMessage(true)}
+ |""".stripMargin,
+ JSONFactory700ConsumerAccountAccess.postConsumerAccountAccessJsonV700Example,
+ JSONFactory700ConsumerAccountAccess.consumerAccountAccessJsonV700Example,
+ List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView,
+ InvalidJsonFormat, CannotGrantConsumerAccountAccessToView,
+ UserLacksPermissionCanGrantAccessToSystemViewForTargetAccount,
+ UserLacksPermissionCanGrantAccessToCustomViewForTargetAccount,
+ SystemViewNotFound, ViewNotFound, ConsumerNotFoundByConsumerId, ConsumerAccountAccessAlreadyExists, UnknownError),
+ List(apiTagAccountAccess, apiTagView, apiTagConsumer),
+ None,
+ http4sPartialFunction = Some(grantConsumerAccessToView)
+ )
+
+ // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access
+ lazy val getConsumerAccountAccess: Http4sRoute = Http4sRoute {
+ case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" / _ / "consumer-account-access" =>
+ EndpointHelpers.withView(req) { (_, account, view, cc) =>
+ for {
+ _ <- Helper.booleanToFuture(
+ s"$UserNoPermissionAccessView The view ${view.viewId.value} needs the `$CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS` permission.",
+ failCode = 403, cc = Some(cc)) {
+ view.allowed_actions.exists(_ == CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS)
+ }
+ list <- Future(provider.getByAccount(account.bankId, account.accountId)) map { x => fullOrFail(x, cc) }
+ } yield JSONFactory700ConsumerAccountAccess.createConsumerAccountAccessesJson(list)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getConsumerAccountAccess),
+ "GET",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access",
+ "Get Consumer access to Account",
+ s"""The Consumers given access to views of this account: for each, the view, who granted it and when.
+ |
+ |The caller's view VIEW_ID needs the `$CAN_SEE_VIEWS_WITH_PERMISSIONS_FOR_ALL_USERS` permission, as for
+ |seeing which Users have access to the account.
+ |
+ |$description
+ |
+ |${userAuthenticationMessage(true)}
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700ConsumerAccountAccess.consumerAccountAccessesJsonV700Example,
+ List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError),
+ List(apiTagAccountAccess, apiTagView, apiTagConsumer),
+ None,
+ http4sPartialFunction = Some(getConsumerAccountAccess)
+ )
+
+ // Route: DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access/CONSUMER_ACCOUNT_ACCESS_ID (204)
+ lazy val revokeConsumerAccessToView: Http4sRoute = Http4sRoute {
+ case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" / _ / "consumer-account-access" / consumerAccountAccessId
+ if consumerAccountAccessId.nonEmpty =>
+ EndpointHelpers.executeDelete(req) { cc =>
+ val (user, account, view) = callerAndView(cc)
+ for {
+ grant <- Future(provider.get(account.bankId, account.accountId, consumerAccountAccessId)) map {
+ x => code.api.util.APIUtil.unboxFullOrFail(x, Some(cc), ConsumerAccountAccessNotFound, 404)
+ }
+ targetViewId = ViewId(grant.viewId)
+ _ <- Helper.booleanToFuture(getUserLacksRevokePermissionErrorMessage(view.viewId, targetViewId), failCode = 403, cc = Some(cc)) {
+ canRevokeAccessToView(BankIdAccountIdViewId(account.bankId, account.accountId, view.viewId), targetViewId, user, Some(cc))
+ }
+ _ <- Future(provider.revoke(grant.consumerAccountAccessId)) map { x => fullOrFail(x, cc) }
+ } yield ()
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(revokeConsumerAccessToView),
+ "DELETE",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access/CONSUMER_ACCOUNT_ACCESS_ID",
+ "Revoke Consumer access to View",
+ s"""Take away a Consumer's access to a view of this account.
+ |
+ |The caller revokes it through their own view VIEW_ID, with the same permissions as revoking a view from a
+ |User: for a system view, VIEW_ID must list it in `can_revoke_access_to_views`; for a custom view, VIEW_ID
+ |must have `can_revoke_access_to_custom_views`.
+ |
+ |$description
+ |
+ |${userAuthenticationMessage(true)}
+ |""".stripMargin,
+ EmptyBody,
+ EmptyBody,
+ List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView,
+ ConsumerAccountAccessNotFound,
+ UserLacksPermissionCanRevokeAccessToSystemViewForTargetAccount,
+ UserLacksPermissionCanRevokeAccessToCustomViewForTargetAccount, UnknownError),
+ List(apiTagAccountAccess, apiTagView, apiTagConsumer),
+ None,
+ http4sPartialFunction = Some(revokeConsumerAccessToView)
+ )
+}
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700ConsumerAccountAccess.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700ConsumerAccountAccess.scala
new file mode 100644
index 0000000000..1d329a394a
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700ConsumerAccountAccess.scala
@@ -0,0 +1,83 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import java.util.Date
+
+import code.api.util.ExampleValue
+import code.consumeraccountaccess.ConsumerAccountAccessTrait
+
+/*
+ * The JSON of the v7.0.0 Consumer account access endpoints. Package-level case classes, for the reason given in
+ * JSONFactory700Operations.
+ */
+
+/** Give a Consumer access to a view of this account. */
+case class PostConsumerAccountAccessJsonV700(consumer_id: String, view_id: String)
+
+case class ConsumerAccountAccessJsonV700(
+ consumer_account_access_id: String,
+ consumer_id: String,
+ bank_id: String,
+ account_id: String,
+ view_id: String,
+ granted_by_user_id: String,
+ created_at: Date
+)
+
+case class ConsumerAccountAccessesJsonV700(consumer_account_access: List[ConsumerAccountAccessJsonV700])
+
+object JSONFactory700ConsumerAccountAccess {
+
+ def createConsumerAccountAccessJson(a: ConsumerAccountAccessTrait): ConsumerAccountAccessJsonV700 =
+ ConsumerAccountAccessJsonV700(
+ consumer_account_access_id = a.consumerAccountAccessId,
+ consumer_id = a.consumerId,
+ bank_id = a.bankId,
+ account_id = a.accountId,
+ view_id = a.viewId,
+ granted_by_user_id = a.grantedByUserId,
+ created_at = a.createdAt)
+
+ def createConsumerAccountAccessesJson(list: List[ConsumerAccountAccessTrait]): ConsumerAccountAccessesJsonV700 =
+ ConsumerAccountAccessesJsonV700(list.map(createConsumerAccountAccessJson))
+
+ lazy val postConsumerAccountAccessJsonV700Example = PostConsumerAccountAccessJsonV700(
+ consumer_id = ExampleValue.consumerIdExample.value,
+ view_id = "_payment-initiation")
+
+ lazy val consumerAccountAccessJsonV700Example = ConsumerAccountAccessJsonV700(
+ consumer_account_access_id = "2bb6a0c4-0d8b-4c1b-9f5e-4a9d3c1f7e21",
+ consumer_id = ExampleValue.consumerIdExample.value,
+ bank_id = ExampleValue.bankIdExample.value,
+ account_id = ExampleValue.accountIdExample.value,
+ view_id = "_payment-initiation",
+ granted_by_user_id = ExampleValue.userIdExample.value,
+ created_at = new Date())
+
+ lazy val consumerAccountAccessesJsonV700Example = ConsumerAccountAccessesJsonV700(List(consumerAccountAccessJsonV700Example))
+}
diff --git a/obp-api/src/main/scala/code/consumeraccountaccess/ConsumerAccountAccess.scala b/obp-api/src/main/scala/code/consumeraccountaccess/ConsumerAccountAccess.scala
new file mode 100644
index 0000000000..e71f7d9d21
--- /dev/null
+++ b/obp-api/src/main/scala/code/consumeraccountaccess/ConsumerAccountAccess.scala
@@ -0,0 +1,65 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.consumeraccountaccess
+
+import java.util.Date
+
+import com.openbankproject.commons.model.{AccountId, BankId, ViewId}
+import net.liftweb.common.Box
+import net.liftweb.util.SimpleInjector
+
+/**
+ * Account access granted to a Consumer: the Consumer, acting as itself, may use this view of this account.
+ *
+ * It sits next to account access granted to a User (code.views.system.AccountAccess) and is kept in its own
+ * table. AccountAccess.consumer_id means something else: that a User's grant may only be used through that
+ * Consumer. The grant is consulted only for an application acting as itself (its own client-credentials
+ * User), and only on endpoints whose auth mode admits an application (see APIUtil.hasConsumerAccountAccess).
+ */
+object ConsumerAccountAccesses extends SimpleInjector {
+ val consumerAccountAccessProvider = new Inject(() => buildOne) {}
+ def buildOne: ConsumerAccountAccessProvider = ConsumerAccountAccessDbProvider
+}
+
+trait ConsumerAccountAccessTrait {
+ def consumerAccountAccessId: String
+ /** The Consumer's consumer_id (its public id, not its key). */
+ def consumerId: String
+ def bankId: String
+ def accountId: String
+ def viewId: String
+ def grantedByUserId: String
+ def createdAt: Date
+}
+
+trait ConsumerAccountAccessProvider {
+ def grant(consumerId: String, bankId: BankId, accountId: AccountId, viewId: ViewId, grantedByUserId: String): Box[ConsumerAccountAccessTrait]
+ def get(bankId: BankId, accountId: AccountId, consumerAccountAccessId: String): Box[ConsumerAccountAccessTrait]
+ def find(consumerId: String, bankId: BankId, accountId: AccountId, viewId: ViewId): Box[ConsumerAccountAccessTrait]
+ def getByAccount(bankId: BankId, accountId: AccountId): Box[List[ConsumerAccountAccessTrait]]
+ def revoke(consumerAccountAccessId: String): Box[Boolean]
+}
diff --git a/obp-api/src/main/scala/code/consumeraccountaccess/ConsumerAccountAccessTable.scala b/obp-api/src/main/scala/code/consumeraccountaccess/ConsumerAccountAccessTable.scala
new file mode 100644
index 0000000000..68e32ff1fa
--- /dev/null
+++ b/obp-api/src/main/scala/code/consumeraccountaccess/ConsumerAccountAccessTable.scala
@@ -0,0 +1,122 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.consumeraccountaccess
+
+import java.util.Date
+
+import com.openbankproject.commons.model.{AccountId, BankId, ViewId}
+import net.liftweb.common.Box
+import net.liftweb.mapper._
+import net.liftweb.util.Helpers.tryo
+
+object ConsumerAccountAccessDbProvider extends ConsumerAccountAccessProvider {
+
+ override def grant(consumerId: String, bankId: BankId, accountId: AccountId, viewId: ViewId, grantedByUserId: String): Box[ConsumerAccountAccessTrait] =
+ tryo {
+ ConsumerAccountAccess.create
+ .ConsumerAccountAccessId(java.util.UUID.randomUUID().toString)
+ .ConsumerId(consumerId)
+ .BankId(bankId.value)
+ .AccountId(accountId.value)
+ .ViewId(viewId.value)
+ .GrantedByUserId(grantedByUserId)
+ .saveMe()
+ }
+
+ override def get(bankId: BankId, accountId: AccountId, consumerAccountAccessId: String): Box[ConsumerAccountAccessTrait] =
+ ConsumerAccountAccess.find(
+ By(ConsumerAccountAccess.ConsumerAccountAccessId, consumerAccountAccessId),
+ By(ConsumerAccountAccess.BankId, bankId.value),
+ By(ConsumerAccountAccess.AccountId, accountId.value))
+
+ override def find(consumerId: String, bankId: BankId, accountId: AccountId, viewId: ViewId): Box[ConsumerAccountAccessTrait] =
+ ConsumerAccountAccess.find(
+ By(ConsumerAccountAccess.ConsumerId, consumerId),
+ By(ConsumerAccountAccess.BankId, bankId.value),
+ By(ConsumerAccountAccess.AccountId, accountId.value),
+ By(ConsumerAccountAccess.ViewId, viewId.value))
+
+ override def getByAccount(bankId: BankId, accountId: AccountId): Box[List[ConsumerAccountAccessTrait]] =
+ tryo {
+ ConsumerAccountAccess.findAll(
+ By(ConsumerAccountAccess.BankId, bankId.value),
+ By(ConsumerAccountAccess.AccountId, accountId.value),
+ OrderBy(ConsumerAccountAccess.id, Ascending))
+ }
+
+ override def revoke(consumerAccountAccessId: String): Box[Boolean] =
+ ConsumerAccountAccess.find(By(ConsumerAccountAccess.ConsumerAccountAccessId, consumerAccountAccessId))
+ .flatMap(row => tryo(row.delete_!))
+}
+
+class ConsumerAccountAccess extends ConsumerAccountAccessTrait with LongKeyedMapper[ConsumerAccountAccess] with IdPK {
+ def getSingleton = ConsumerAccountAccess
+
+ object ConsumerAccountAccessId extends MappedString(this, 36) {
+ override def dbColumnName = "consumer_account_access_id"
+ }
+ object ConsumerId extends MappedString(this, 250) {
+ override def dbColumnName = "consumer_id"
+ }
+ object BankId extends MappedString(this, 255) {
+ override def dbColumnName = "bank_id"
+ }
+ object AccountId extends MappedString(this, 255) {
+ override def dbColumnName = "account_id"
+ }
+ object ViewId extends MappedString(this, 255) {
+ override def dbColumnName = "view_id"
+ }
+ object GrantedByUserId extends MappedString(this, 255) {
+ override def dbColumnName = "granted_by_user_id"
+ }
+ object CreationDate extends MappedDateTime(this) {
+ override def dbColumnName = "created_at"
+ override def defaultValue = new Date()
+ }
+ object LastUpdate extends MappedDateTime(this) {
+ override def dbColumnName = "updated_at"
+ override def defaultValue = new Date()
+ }
+
+ override def consumerAccountAccessId: String = ConsumerAccountAccessId.get
+ override def consumerId: String = ConsumerId.get
+ override def bankId: String = BankId.get
+ override def accountId: String = AccountId.get
+ override def viewId: String = ViewId.get
+ override def grantedByUserId: String = GrantedByUserId.get
+ override def createdAt: Date = CreationDate.get
+}
+
+object ConsumerAccountAccess extends ConsumerAccountAccess with LongKeyedMetaMapper[ConsumerAccountAccess] {
+ override def dbTableName = "consumer_account_access"
+ override def dbIndexes =
+ UniqueIndex(ConsumerId, BankId, AccountId, ViewId) ::
+ UniqueIndex(ConsumerAccountAccessId) ::
+ Index(BankId, AccountId) ::
+ super.dbIndexes
+}
diff --git a/obp-api/src/main/scala/code/users/UserReference.scala b/obp-api/src/main/scala/code/users/UserReference.scala
index 760c2e9536..5891ede390 100644
--- a/obp-api/src/main/scala/code/users/UserReference.scala
+++ b/obp-api/src/main/scala/code/users/UserReference.scala
@@ -160,6 +160,7 @@ object UserReference {
case object OpenIDConnectToken_AuthUserPrimaryKey extends UserReference(UseAuthenticatedUserId, "code.token.OpenIDConnectToken", List("AuthUserPrimaryKey"), "token belongs to the login; not attribution")
case object UserRefreshes_UserId extends UserReference(UseAuthenticatedUserId, "code.UserRefreshes.MappedUserRefreshes", List("mUserId"), "operational: refresh of the authenticated user's own account list")
case object PlatformApp_MarkedByUserId extends UserReference(UseAuthenticatedUserId, "code.platformapp.PlatformApp", List("MarkedByUserId"), "audit: who marked the Consumer as a platform app")
+ case object ConsumerAccountAccess_GrantedByUserId extends UserReference(UseAuthenticatedUserId, "code.consumeraccountaccess.ConsumerAccountAccess", List("GrantedByUserId"), "audit: who gave the Consumer access to the view, as Entitlement_GrantedByUserId")
case object GroupMembership_CreatedByUserId extends UserReference(UseAuthenticatedUserId, "code.group.GroupMembership", List("CreatedByUserId"), "audit: who added the member, as Entitlement_GrantedByUserId")
case object AssetStatusHistory_ChangedByUserId extends UserReference(UseAuthenticatedUserId, "code.asset.AssetStatusHistory", List("ChangedByUserId"), "audit: who changed the asset's status, the question a regulator asks")
case object File_ScanStatusUpdatedByUserId extends UserReference(UseAuthenticatedUserId, "code.files.File", List("ScanStatusUpdatedByUserId"), "audit: who set the scan status, the scanner or the staff member who decided")
@@ -269,6 +270,7 @@ object UserReference {
OpenIDConnectToken_AuthUserPrimaryKey,
UserRefreshes_UserId,
PlatformApp_MarkedByUserId,
+ ConsumerAccountAccess_GrantedByUserId,
GroupMembership_CreatedByUserId,
AssetStatusHistory_ChangedByUserId,
RetentionPolicy_CreatedByUserId,
diff --git a/obp-api/src/test/scala/code/api/util/ConsumerQuotaWithoutRedisTest.scala b/obp-api/src/test/scala/code/api/util/ConsumerQuotaWithoutRedisTest.scala
new file mode 100644
index 0000000000..63e8329b13
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/util/ConsumerQuotaWithoutRedisTest.scala
@@ -0,0 +1,49 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+ */
+package code.api.util
+
+import code.api.util.RateLimitingPeriod._
+import code.setup.ServerSetup
+
+/** The consumer quota when Redis can't be asked: a blocked period (limit 0) still refuses, everything else fails open. */
+class ConsumerQuotaWithoutRedisTest extends ServerSetup {
+
+ feature("RateLimitingUtil.refusalWithoutRedis") {
+
+ scenario("a blocked period refuses, the shortest blocked one first") {
+ RateLimitingUtil.refusalWithoutRedis(List(PER_SECOND -> -1L, PER_MINUTE -> 10L, PER_HOUR -> 0L, PER_DAY -> 0L)) shouldBe
+ Some(RateLimitingUtil.Refused(PER_HOUR, 0L, 0L))
+ }
+
+ scenario("without a blocked period the call is allowed (fail open)") {
+ RateLimitingUtil.refusalWithoutRedis(List(PER_SECOND -> -1L, PER_MINUTE -> 10L, PER_HOUR -> 1000L)) shouldBe None
+ }
+
+ scenario("anonymous access blocked with a per-hour limit of 0 refuses too") {
+ RateLimitingUtil.refusalWithoutRedis(List(PER_HOUR -> 0L)) shouldBe Some(RateLimitingUtil.Refused(PER_HOUR, 0L, 0L))
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/ConsumerAccountAccessTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerAccountAccessTest.scala
new file mode 100644
index 0000000000..3d78340cf3
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerAccountAccessTest.scala
@@ -0,0 +1,224 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import code.api.Constant.{CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT, SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID, SYSTEM_OWNER_VIEW_ID}
+import code.api.util.APIUtil
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ErrorMessages.{CannotGrantConsumerAccountAccessToView, ConsumerAccountAccessAlreadyExists, ConsumerAccountAccessNotFound, ConsumerNotFoundByConsumerId}
+import code.api.v6_0_0.V600ServerSetup
+import code.consumeraccountaccess.ConsumerAccountAccesses
+import code.transactionrequests.MappedTransactionRequest
+import code.views.Views
+import com.openbankproject.commons.model.{BankIdAccountId, CreateViewJson, ErrorMessage, ViewId}
+import com.openbankproject.commons.util.ApiVersion
+import net.liftweb.mapper.By
+import org.json4s.JsonDSL._
+import org.json4s.native.JsonMethods.{compact, render}
+import org.scalatest.Tag
+
+/** Account access granted to a Consumer, used by the application acting as itself (client credentials). */
+class ConsumerAccountAccessTest extends V600ServerSetup {
+
+ def v7 = baseRequest / "obp" / "v7.0.0"
+
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object ApiEndpoint1 extends Tag("grantConsumerAccessToView")
+ object ApiEndpoint2 extends Tag("getConsumerAccountAccess")
+ object ApiEndpoint3 extends Tag("revokeConsumerAccessToView")
+ object ApiEndpoint4 extends Tag("createTransactionRequestOpenCorridor")
+
+ private val bankId = testBankId1
+ private val accountId = testAccountId0
+
+ private def access(viewId: String) =
+ v7 / "banks" / bankId.value / "accounts" / accountId.value / "views" / viewId / "consumer-account-access"
+ private def grantBody(consumerId: String, viewId: String) =
+ compact(render(("consumer_id" -> consumerId) ~ ("view_id" -> viewId)))
+ private def promise(viewId: String) =
+ v7 / "banks" / bankId.value / "accounts" / accountId.value / viewId / "transaction-request-types" /
+ "OPEN_CORRIDOR_PROMISE" / "transaction-requests"
+ private def message(r: code.setup.APIResponse) = r.body.extract[ErrorMessage].message
+
+ private def accountCurrency: String =
+ code.bankconnectors.Connector.connector.vend.getBankAccountLegacy(bankId, accountId, None)
+ .map(_._1.currency).openOrThrowException("test account")
+
+ private def promiseBody: String =
+ s"""{
+ | "to": {
+ | "name": "Beneficiary ${APIUtil.generateUUID().take(8)}",
+ | "description": "Beneficiary at receiving institution",
+ | "other_bank_routing_scheme": "OBP",
+ | "other_bank_routing_address": "${testBankId2.value}",
+ | "other_branch_routing_scheme": "",
+ | "other_branch_routing_address": "",
+ | "other_account_routing_scheme": "OBP",
+ | "other_account_routing_address": "${testAccountId1.value}",
+ | "other_account_secondary_routing_scheme": "",
+ | "other_account_secondary_routing_address": ""
+ | },
+ | "value": {"currency": "$accountCurrency", "amount": "2.00"},
+ | "description": "Consumer account access test payment",
+ | "charge_policy": "SHARED",
+ | "originator": {
+ | "name": "Alice Sender",
+ | "address": "1 Sender Street, London, UK",
+ | "account_routing": {"scheme": "IBAN", "address": "GB29 NWBK 6016 1331 9268 19"}
+ | }
+ |}""".stripMargin
+
+ /** user1 holds owner on the test accounts; custom views are granted through the ManageCustomViews view. */
+ private val Manage = SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID
+ private def user1ManagesCustomViews(): Unit =
+ Views.views.vend.grantAccessToSystemView(bankId, accountId,
+ Views.views.vend.systemView(ViewId(Manage)).openOrThrowException("ManageCustomViews"), resourceUser1)
+
+ /** A custom view of the test account with only the permission to create Transaction Requests. */
+ private def createPaymentInitiationView(): String = {
+ user1ManagesCustomViews()
+ val name = "_pay" + APIUtil.generateUUID().take(8)
+ Views.views.vend.createCustomView(
+ BankIdAccountId(bankId, accountId),
+ CreateViewJson(
+ name = name,
+ description = "payment initiation only",
+ metadata_view = name,
+ is_public = false,
+ which_alias_to_use = "",
+ hide_metadata_if_alias_used = false,
+ allowed_actions = List(CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT)
+ )
+ ).openOrThrowException("custom view").viewId.value
+ }
+
+ private def revokeAll(): Unit =
+ ConsumerAccountAccesses.consumerAccountAccessProvider.vend.getByAccount(bankId, accountId).openOr(Nil)
+ .foreach(a => ConsumerAccountAccesses.consumerAccountAccessProvider.vend.revoke(a.consumerAccountAccessId))
+
+ feature("Consumer account access") {
+
+ scenario("an application acting as itself initiates a payment through a view granted to its Consumer, until it is revoked",
+ ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) {
+ setPropsValues("open_corridor_enabled" -> "true")
+ revokeAll()
+ val viewId = createPaymentInitiationView()
+ val consumerId = testConsumer.consumerId.get
+
+ When("the application creates a promise through the view before its Consumer has access")
+ val before = makePostRequest(promise(viewId).POST <@ (application1), promiseBody)
+ Then("it is refused")
+ before.code should equal(403)
+
+ When("the account holder gives the Consumer access to the view")
+ val granted = makePostRequest(access(Manage).POST <@ (user1), grantBody(consumerId, viewId))
+ granted.code should equal(201)
+ (granted.body \ "consumer_id").extract[String] should equal(consumerId)
+ (granted.body \ "view_id").extract[String] should equal(viewId)
+ (granted.body \ "granted_by_user_id").extract[String] should equal(resourceUser1.userId)
+ val grantId = (granted.body \ "consumer_account_access_id").extract[String]
+
+ And("giving it again is refused")
+ val again = makePostRequest(access(Manage).POST <@ (user1), grantBody(consumerId, viewId))
+ again.code should equal(409)
+ message(again) should include(ConsumerAccountAccessAlreadyExists)
+
+ When("the application creates a promise through the view")
+ val created = makePostRequest(promise(viewId).POST <@ (application1), promiseBody)
+ Then("it is created, held at PENDING, and records the Consumer")
+ created.code should equal(201)
+ (created.body \ "status").extract[String] should equal("PENDING")
+ val trId = (created.body \ "id").extract[String]
+ MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, trId))
+ .map(_.mConsumerId.get).openOr("") should equal(consumerId)
+
+ And("the account holder sees the grant")
+ val listed = makeGetRequest(access(SYSTEM_OWNER_VIEW_ID).GET <@ (user1))
+ listed.code should equal(200)
+ (listed.body \ "consumer_account_access").children.map(a => (a \ "consumer_account_access_id").extract[String]) should contain(grantId)
+
+ When("the account holder revokes it")
+ makeDeleteRequest(access(Manage) / grantId <@ (user1)).code should equal(204)
+ And("revoking it again finds nothing")
+ val gone = makeDeleteRequest(access(Manage) / grantId <@ (user1))
+ gone.code should equal(404)
+ message(gone) should include(ConsumerAccountAccessNotFound)
+
+ Then("the application can no longer create a promise through the view")
+ makePostRequest(promise(viewId).POST <@ (application1), promiseBody).code should equal(403)
+ }
+
+ scenario("a person calling through the Consumer does not get the Consumer's access", ApiEndpoint4, VersionOfApi) {
+ setPropsValues("open_corridor_enabled" -> "true")
+ revokeAll()
+ val viewId = createPaymentInitiationView()
+ Given("the view is granted to testConsumer2, the Consumer user2 calls through")
+ makePostRequest(access(Manage).POST <@ (user1), grantBody(testConsumer2.consumerId.get, viewId)).code should equal(201)
+ try {
+ When("user2, a person, creates a promise through the view")
+ val response = makePostRequest(promise(viewId).POST <@ (user2), promiseBody)
+ Then("it is refused: the grant is the application's, not its Users'")
+ response.code should equal(403)
+ } finally revokeAll()
+ }
+
+ scenario("the grant does not count on an endpoint that does not admit an application", ApiEndpoint2, VersionOfApi) {
+ revokeAll()
+ val viewId = createPaymentInitiationView()
+ makePostRequest(access(Manage).POST <@ (user1), grantBody(testConsumer.consumerId.get, viewId)).code should equal(201)
+ try {
+ When("the application calls a User-only endpoint of the same view")
+ val response = makeGetRequest(access(viewId).GET <@ (application1))
+ Then("its Consumer's access does not let it through")
+ response.code should equal(403)
+ } finally revokeAll()
+ }
+
+ scenario("the owner view, an unknown Consumer, a caller without access and an anonymous caller are refused",
+ ApiEndpoint1, VersionOfApi) {
+ revokeAll()
+ val viewId = createPaymentInitiationView()
+
+ val owner = makePostRequest(access(SYSTEM_OWNER_VIEW_ID).POST <@ (user1), grantBody(testConsumer.consumerId.get, SYSTEM_OWNER_VIEW_ID))
+ owner.code should equal(400)
+ message(owner) should include(CannotGrantConsumerAccountAccessToView)
+
+ val unknown = makePostRequest(access(Manage).POST <@ (user1), grantBody("no-such-consumer", viewId))
+ unknown.code should equal(404)
+ message(unknown) should include(ConsumerNotFoundByConsumerId)
+
+ Then("user2, who has no access to the account, can't grant")
+ makePostRequest(access(Manage).POST <@ (user2), grantBody(testConsumer.consumerId.get, viewId)).code should equal(403)
+
+ Then("nobody can grant anonymously")
+ makePostRequest(access(Manage).POST, grantBody(testConsumer.consumerId.get, viewId)).code should equal(401)
+
+ ConsumerAccountAccesses.consumerAccountAccessProvider.vend
+ .find(testConsumer.consumerId.get, bankId, accountId, ViewId(viewId)).isEmpty should equal(true)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/concurrency/ConcurrentConsumerQuotaScriptTest.scala b/obp-api/src/test/scala/code/concurrency/ConcurrentConsumerQuotaScriptTest.scala
index e2abe50ec7..12a26b1dd0 100644
--- a/obp-api/src/test/scala/code/concurrency/ConcurrentConsumerQuotaScriptTest.scala
+++ b/obp-api/src/test/scala/code/concurrency/ConcurrentConsumerQuotaScriptTest.scala
@@ -169,5 +169,31 @@ class ConcurrentConsumerQuotaScriptTest extends ConcurrentRaceSetup {
}
keys.map(count) should equal(List(limit, limit))
}
+
+ scenario("Q7: a full per-second counter still refuses in the second half of its window, where TTL reads 0", ConcurrencyRace) {
+ RedisTestTarget.requireReachable(redisUp, "Q7")
+ Given("a per-second limit of 2, reached")
+ val keys = freshKeys(1)
+ call(keys, List(2L), List(1))
+ call(keys, List(2L), List(1))
+ When("more than half of the second has passed: TTL rounds to 0 while the counter still holds 2")
+ Thread.sleep(600)
+ assume(ttl(keys.head) == 0L && count(keys.head) == 2L, "the window ended before the check (slow machine)")
+ Then("the third call is refused, with a reset of 1 second, and nothing is counted")
+ call(keys, List(2L), List(1)) should equal(QuotaRefused(0, 2L, 1L))
+ count(keys.head) should equal(2L)
+ }
+
+ scenario("Q8: a call served in the second half of a per-second window reports a reset of 1 second, not 0", ConcurrencyRace) {
+ RedisTestTarget.requireReachable(redisUp, "Q8")
+ Given("a per-second limit of 5 with one call counted")
+ val keys = freshKeys(1)
+ call(keys, List(5L), List(1))
+ Thread.sleep(600)
+ assume(ttl(keys.head) == 0L, "the window ended before the check (slow machine)")
+ When("a second call is counted in the same window")
+ Then("its count is 2 and its TTL is rounded up, so the X-Rate-Limit-* headers are still set")
+ call(keys, List(5L), List(1)) should equal(QuotaCounted(List((2L, 1L))))
+ }
}
}
diff --git a/release_notes.md b/release_notes.md
index a90d3d7a48..0eae72a5cb 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -3,6 +3,32 @@
### Most recent changes at top of file
```
Date Commit Action
+06/11/2026 TBD FIXED: the consumer quota. A per-second limit held for only about half of each second:
+ Redis rounds TTL to the nearest second, so a 1-second counter read TTL 0 for its last
+ ~500 ms while still holding its count, and was treated as expired, letting every call
+ through. The quota script now decides on PTTL, and reports the X-Rate-Limit-Reset
+ rounded up, so a live counter never reads 0 (the headers were also missing in that
+ half-second). And a Consumer blocked with a limit of 0 is refused again while Redis is
+ unreachable, as before the single-script quota; other limits still fail open, now with
+ a warning per request instead of an error with a stack trace.
+06/11/2026 TBD ADDED: Consumer Account Access. A view of an account can be granted to a Consumer, as it
+ can to a User, so an application acting as itself (OAuth2 client credentials, its own
+ obp-oidc#client User) can use that view. v7.0.0 POST, GET and DELETE
+ /banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access[/CONSUMER_ACCOUNT_
+ ACCESS_ID] grant, list and revoke it, with the same view permissions as granting a view to
+ a User (can_grant_access_to_views / can_grant_access_to_custom_views, and the revoke ones;
+ listing needs can_see_views_with_permissions_for_all_users); the grant records who gave
+ it. The owner view and public views can't be granted to a Consumer (400 OBP-30629); a
+ second grant is 409 OBP-30627; an unknown grant is 404 OBP-30628. The access counts only
+ for the Consumer's own client-credentials User, never for a person logging in through the
+ Consumer or a caller identified by Consumer Key alone, and only on endpoints whose auth
+ mode admits an application (ApplicationOnly, UserXORApplication). New table
+ consumer_account_access. Glossary: Consumer Account Access.
+06/11/2026 TBD CHANGED: v7.0.0 Create Transaction Request (OPEN_CORRIDOR_PROMISE) is UserXORApplication:
+ an application acting as itself may create the promise through a view its Consumer has
+ been given (Consumer Account Access). The Transaction Request records the Consumer, as
+ before. Called with no credentials at all it now answers 401 OBP-20200 (application not
+ identified) instead of 401 OBP-20001.
03/11/2026 TBD CHANGED: the cascade-delete endpoints under /management/cascading are off by
default, behind the new prop allow_cascade_delete (default false). While it is
false they are not routed and not listed in the Resource Docs of any version,
diff --git a/todo/todo_account_access_for_consumers.md b/todo/todo_account_access_for_consumers.md
index e376f8f2ad..a1e0cc52d9 100644
--- a/todo/todo_account_access_for_consumers.md
+++ b/todo/todo_account_access_for_consumers.md
@@ -1,5 +1,14 @@
# TODO — Account access granted to a Consumer
+**Status (2026-11-06): implemented as Consumer Account Access** (`code.consumeraccountaccess`, v7.0.0
+`/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/consumer-account-access`, the check in
+`APIUtil.hasConsumerAccountAccess`, Glossary "Consumer Account Access"). Decisions taken: any view but
+owner and public ones (a dedicated custom view is the recommendation); granted and revoked with the same
+view permissions as for a User; counts only for the Consumer's own client-credentials User and only on
+ApplicationOnly / UserXORApplication endpoints; the Transaction Request already records the Consumer
+(`mConsumerId`), so no data-model change. Still open: questions 4 (declaring required account access as a
+Platform App) and 5 (a challenge above the threshold for an application acting as itself).
+
## Context
OBP has two layers of permission, and only one of them can be granted to a Consumer today: