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: