Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions obp-api/src/main/scala/code/api/OAuth2.scala
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,11 @@
* }
* @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

Check warning on line 403 in obp-api/src/main/scala/code/api/OAuth2.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaGw22qMLjZQeDKjfO6W&open=AaGw22qMLjZQeDKjfO6W&pullRequest=2964
// 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)
Expand Down
38 changes: 24 additions & 14 deletions obp-api/src/main/scala/code/api/cache/Redis.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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")
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
33 changes: 33 additions & 0 deletions obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1573,7 +1573,7 @@
|
|There is a one to one relationship between a Consumer and its certificate. i.e. OBP does not (currently) store the history of certificates bound to a Consumer. If a certificate expires, the third party provider (TPP) must generate a new consumer using a new certificate. In this case, related resources such as rate limits and scopes must be copied from the old consumer to the new consumer. In the future, OBP may store multiple certificates for a consumer, but a certificate will always identify only one consumer record.
|
|A Consumer for an app the installation runs itself (the Portal, the API Manager, Opey, OBP-Sentinel or a bank's own service) that calls OBP with its own application token needs Roles granted to it as Scopes. An administrator marks it as a Platform App, the app declares the Scopes it needs, and the administrator grants them. See ${getGlossaryItemLink("Platform Apps")}

Check failure on line 1576 in obp-api/src/main/scala/code/api/util/Glossary.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Platform Apps" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaGw22YxLjZQeDKjfO6U&open=AaGw22YxLjZQeDKjfO6U&pullRequest=2964
|
""")

Expand Down Expand Up @@ -7428,6 +7428,39 @@
|- [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(
Expand Down
21 changes: 17 additions & 4 deletions obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)] = {
Expand Down
6 changes: 6 additions & 0 deletions obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -4800,6 +4802,7 @@ object Http4s700 {
InsufficientAuthorisationToCreateTransactionRequest, UnknownError),
apiTagTransactionRequest :: Nil,
None,
authMode = UserXORApplication,
http4sPartialFunction = Some(createTransactionRequestOpenCorridor)
)

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading