diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index 1398389be6..8edce53e85 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -568,8 +568,8 @@ class Boot extends MdcLoggable {
// DirectLogin (POST /my/logins/direct) and aliveCheck (GET /alive) are now served
// by their native http4s counterparts wired into Http4sApp.baseServices
// (DirectLoginRoutes / AliveCheckRoutes). The Lift dispatches were retired in the
- // http4s migration; any prop gates (e.g. `allow_direct_login`) live with those
- // routes. The OBP-as-relying-party OpenID Connect callback was removed: OBP is a
+ // http4s migration; any prop gates live with those routes (`allow_direct_login` is
+ // checked in DirectLoginRoutes.issueToken, which the v6.0.0 route also uses). The OBP-as-relying-party OpenID Connect callback was removed: OBP is a
// pure OAuth2 resource server (Bearer-JWT validation), login is done by the client/BFF.
//////////////////////////////////////////////////////////////////////////////////////////////////
diff --git a/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala b/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala
index b269e44b8a..1123c03629 100644
--- a/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala
+++ b/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala
@@ -78,27 +78,39 @@ object DirectLoginRoutes {
}.toMap
}
- private val directLoginAllowed: Boolean =
- code.api.util.APIUtil.getPropsAsBoolValue("allow_direct_login", true)
+ /**
+ * This checks DirectLogin credentials and issues a token. It is the one implementation behind both the
+ * unversioned POST /my/logins/direct and the versioned POST /obp/v6.0.0/my/logins/direct, so the two cannot drift.
+ *
+ * When allow_direct_login is false it refuses before reading any credentials, so the endpoint neither checks
+ * passwords nor stores tokens on an instance where DirectLogin is turned off. The prop is read on each request.
+ */
+ def issueToken(cc: CallContext): Future[code.api.v6_0_0.TokenJSON] = {
+ val directLoginAllowed = code.api.util.APIUtil.getPropsAsBoolValue("allow_direct_login", true)
+ for {
+ _ <- code.util.Helper.booleanToFuture(code.api.util.ErrorMessages.DirectLoginIsDisabled, failCode = 403, cc = Some(cc)) {
+ directLoginAllowed
+ }
+ // If the parser found nothing usable, fall back to a single "error" key so that
+ // validatorFutureWithParams returns the MissingDirectLoginHeader message.
+ parsed = parseDirectLoginParams(cc)
+ params = if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader) else parsed
+ triple <- DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params)
+ (httpCode, message, dlParams) = triple
+ tokenTriple = DirectLogin.createTokenCommonPart(httpCode, message, dlParams)
+ _ <- Future(DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3))
+ } yield {
+ if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2)
+ else code.api.util.APIUtil.unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1)
+ }
+ }
- val routes: HttpRoutes[IO] = if (!directLoginAllowed) HttpRoutes.empty[IO] else HttpRoutes.of[IO] {
+ val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> Root / "my" / "logins" / "direct" =>
Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc =>
val reqWithCC = req.withAttribute(Http4sRequestAttributes.callContextKey, cc)
EndpointHelpers.executeFutureCreated(reqWithCC) {
- val parsed = parseDirectLoginParams(cc)
- val params =
- if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader)
- else parsed
- for {
- triple <- DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params)
- (httpCode, message, dlParams) = triple
- tokenTriple = DirectLogin.createTokenCommonPart(httpCode, message, dlParams)
- _ <- Future(DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3))
- } yield {
- if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2)
- else code.api.util.APIUtil.unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1)
- }
+ issueToken(cc)
}
}
}
diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala
index 0ee0e4e564..a90f6bcb39 100644
--- a/obp-api/src/main/scala/code/api/OAuth2.scala
+++ b/obp-api/src/main/scala/code/api/OAuth2.scala
@@ -529,11 +529,20 @@ object OAuth2Login extends MdcLoggable {
*/
def getOrCreateConsumer(jwtToken: String, userId: Box[String], description: Option[String]): Box[Consumer] = {
val aud = Some(JwtUtil.getAudience(jwtToken).mkString(","))
- val azp = getClaim(name = "azp", jwtToken = jwtToken)
+ // azp identifies the OAuth client. Some IdPs put the client only in client_id (RFC 9068),
+ // e.g. on client_credentials tokens, so fall back to that.
+ val azp = getClaim(name = "azp", jwtToken = jwtToken).filter(_.nonEmpty)
+ .orElse(getClaim(name = "client_id", jwtToken = jwtToken).filter(_.nonEmpty))
val iss = getClaim(name = "iss", jwtToken = jwtToken)
val sub = getClaim(name = "sub", jwtToken = jwtToken)
val email = getClaim(name = "email", jwtToken = jwtToken)
val name = getClaim(name = "name", jwtToken = jwtToken).orElse(description)
+ // Without a client identifier no lookup can ever match, so every request would auto-create
+ // a new Consumer. Refuse instead.
+ if (azp.isEmpty) {
+ logger.warn(s"getOrCreateConsumer says: token from iss=${iss.getOrElse("None")} sub=${sub.getOrElse("None")} has neither azp nor client_id claim; not creating a Consumer")
+ return Failure(s"$Oauth2TokenHaveNoConsumer The token has neither an azp nor a client_id claim.")
+ }
Consumers.consumers.vend.getOrCreateConsumer(
consumerId = None,
key = None,
diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala
index 874a0ca74c..d30800a8ab 100644
--- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala
+++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala
@@ -2994,7 +2994,7 @@ object SwaggerDefinitionsJSON {
lazy val consumerJsonV600: ConsumerJsonV600 = ConsumerJsonV600(
consumer_id = consumerIdExample.value,
- consumer_key = consumerKeyExample.value,
+ consumer_key = code.api.util.APIUtil.RedactedConsumerKey,
app_name = appNameExample.value,
app_type = appTypeExample.value,
description = descriptionExample.value,
@@ -6126,7 +6126,7 @@ object SwaggerDefinitionsJSON {
)
lazy val consumersJsonV510 = ConsumersJsonV510(
- List(consumerJsonV510)
+ List(consumerJsonV510.copy(consumer_key = code.api.util.APIUtil.RedactedConsumerKey))
)
lazy val agentIdJson = AgentCashWithdrawalJson(
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 bf869183d2..0fd86771d7 100644
--- a/obp-api/src/main/scala/code/api/cache/Redis.scala
+++ b/obp-api/src/main/scala/code/api/cache/Redis.scala
@@ -309,6 +309,36 @@ object Redis extends MdcLoggable {
(result.get(0).longValue(), result.get(1).longValue())
}
+ /**
+ * HSET field to value in KEYS[1] only when the field is absent or holds a smaller number, so
+ * writers that arrive out of order (several nodes, async writes) never move a value backwards.
+ * Also SET KEYS[2] to the same value if it does not exist yet: it records when the hash started
+ * filling, which is when its values became meaningful (the hash is empty after a Redis restart).
+ */
+ private val hashSetIfGreaterScript =
+ """local current = redis.call('HGET', KEYS[1], ARGV[1])
+ |if (not current) or tonumber(current) < tonumber(ARGV[2]) then
+ | redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
+ |end
+ |redis.call('SET', KEYS[2], ARGV[2], 'NX')
+ |return 1""".stripMargin
+
+ /** See hashSetIfGreaterScript. One round trip, atomic server-side. */
+ def hashSetIfGreater(hashKey: String, sinceKey: String, field: String, value: Long): Unit = withJedis { jedis =>
+ jedis.eval(
+ hashSetIfGreaterScript,
+ java.util.Arrays.asList(hashKey, sinceKey),
+ java.util.Arrays.asList(field, value.toString)
+ )
+ ()
+ }
+
+ /** HGETALL; an absent key is an empty map. */
+ def hashGetAll(hashKey: String): Map[String, String] = withJedis { jedis =>
+ import scala.jdk.CollectionConverters._
+ jedis.hgetAll(hashKey).asScala.toMap
+ }
+
/**
* Delete all Redis keys matching a pattern using KEYS command
* @param pattern Redis key pattern (e.g., "rl_active_CONSUMER123_*")
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 b93dabc9ed..3d6e2dfd64 100644
--- a/obp-api/src/main/scala/code/api/util/APIUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala
@@ -2584,6 +2584,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
case _ =>
// There are no enough information for logging
}
+ callContext.foreach(cc => WriteMetricUtil.recordConsumerLastCall(cc.consumerId, cc.startTime))
WriteMetricUtil.writeEndpointMetric(responseBody, callContext.map(_.copy(endTime = Some(endTime))))
result
}
@@ -3883,6 +3884,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
else value
}
+ // A consumer key is a credential. Response formats that carry a consumer_key field from before
+ // this rule keep the field and return this value; newer versions leave the field out.
+ // Create responses still return the real key: that is how the creator learns it.
+ val RedactedConsumerKey = "**redacted**"
+
+ def redactConsumerKey(consumerKey: String): String = RedactedConsumerKey
+
// Convention-based public app URL props.
// Every key registered here appears in getConfigPropsPairs and so in the App Directory.
// The set is fixed in code: an operator-added public_*_url prop is NOT picked up
diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala
index 9b797377d9..52a9aaa861 100644
--- a/obp-api/src/main/scala/code/api/util/ApiRole.scala
+++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala
@@ -770,7 +770,9 @@ object ApiRole extends MdcLoggable{
case class CanDeleteUserAuthContext(requiresBankId: Boolean = false) extends ApiRole
lazy val canDeleteUserAuthContext = CanDeleteUserAuthContext()
- case class CanCreateUserAuthContextUpdate(requiresBankId: Boolean = false) extends ApiRole
+ // Starting a User Auth Context Update (which emails a challenge to a Customer of the bank). Held either by the
+ // App, as a Scope, or by the User, as an Entitlement, always at one bank.
+ case class CanCreateUserAuthContextUpdate(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateUserAuthContextUpdate = CanCreateUserAuthContextUpdate()
case class CanGetTaxResidence(requiresBankId: Boolean = true) extends ApiRole
diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala
index d091622ccb..391487ad6b 100644
--- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala
@@ -372,10 +372,8 @@ object Consent extends MdcLoggable {
if(requestConsumerId == "NONE" || consumerValidationMethodForConsent.isEmpty) {
logger.warn(s"consumer_validation_method_for_consent is empty while request consumer_id=NONE - consent_id=${consent.jti}, aud=${consent.aud}")
}
- // Get consumer keys for debugging
- val consentConsumerKey = Consumers.consumers.vend.getConsumerByConsumerId(consentConsumerId).map(_.key.get).getOrElse("Unknown")
- val requestConsumerKey = callContext.consumer.map(_.key.get).getOrElse("None")
- val detailedErrorMsg = s"${ErrorMessages.ConsentNotFound} Consumer mismatch: consent has consumer_id='$consentConsumerId' (consumer_key='$consentConsumerKey'), but current request has consumer_id='$requestConsumerId' (consumer_key='$requestConsumerKey')"
+ // Consumers are identified by consumer_id: the key is a credential and never goes into a log
+ val detailedErrorMsg = s"${ErrorMessages.ConsentNotFound} Consumer mismatch: consent has consumer_id='$consentConsumerId', but current request has consumer_id='$requestConsumerId'"
logger.debug(s"ConsentNotFound: TPP/Consumer mismatch. Consent holder consumer_id=$consentConsumerId, Request consumer_id=$requestConsumerId, consent_id=${consent.jti}")
logger.debug(s"ConsentNotFound: $detailedErrorMsg")
ErrorUtil.apiFailureToBox(ErrorMessages.ConsentNotFound, 401)(Some(callContext))
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 a3f6aba03a..10a49d5e3d 100644
--- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
+++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
@@ -304,6 +304,7 @@ object ErrorMessages {
val InvalidDAuthHeaderToken = "OBP-20072: DAuth Header value should be one single string."
val DAuthIsDisabled = "OBP-20407: DAuth is disabled. Set allow_dauth=true to enable."
val OpenIDConnectIsDisabled = "OBP-20408: OpenID Connect is disabled. Set allow_openid_connect=true to enable."
+ val DirectLoginIsDisabled = "OBP-20409: DirectLogin is disabled. Set allow_direct_login=true to enable."
val InvalidProviderUrl = "OBP-20079: Cannot match the local identity provider."
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 cb330f0a3e..c144ad17cf 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -4359,6 +4359,8 @@ object Glossary extends MdcLoggable {
|
|**Managing.** `/obp/v7.0.0/management/banks/BANK_ID/domain-apis` creates and lists a space's Domain APIs, and `/obp/v7.0.0/management/banks/BANK_ID/domain-apis/DOMAIN_API_ID` reads, updates and deletes one. BANK_ID is a bank's id or `SYS`. The Roles are CanCreateDomainApi, CanGetDomainApis, CanUpdateDomainApi and CanDeleteDomainApi, held at that BANK_ID.
|
+|**Discovery.** A Domain API whose `is_publicly_discoverable` is true is listed, without logging in, at `/obp/v7.0.0/banks/BANK_ID/domain-apis`, with its base path, version, title, description, URL and OpenAPI document. It is false unless set, so a Domain API is only listed publicly on purpose. It only controls that listing: the calls under the base path and the OpenAPI document are served the same either way.
+|
""".stripMargin)
glossaryItems += GlossaryItem(
@@ -7164,6 +7166,100 @@ object Glossary extends MdcLoggable {
""")
+ glossaryItems += GlossaryItem(
+ title = "OBP-Sentinel",
+ description =
+ s"""
+ |# OBP-Sentinel
+ |
+ |**OBP-Sentinel** watches running OBP-API instances, and the apps around them (API Explorer II, the API Manager, the Portal, OBP-OIDC), for hours. It works out what is actually going wrong or getting slower, and suggests the few improvements that matter most. Fewer, better suggestions is the point.
+ |
+ |Watching is plain code and costs nothing. Only the analyst, the source review and the questions a person asks on a finding (Trace history, Look again) use Claude, each with a spending limit.
+ |
+ |## 1. Watching
+ |
+ |Every 2 minutes (by default) Sentinel reads from each instance, with its own token:
+ |
+ |- the **log cache**: the newest log lines for each level (GET /obp/v5.1.0/system/log-cache/LEVEL);
+ |- **Telemetry**: counters and timings, such as how many calls each endpoint gets and how long they take (GET /obp/v7.0.0/management/telemetry);
+ |- **aggregate metrics**: calls, response times, users and consumers, once per 15-minute period after it ends (GET /obp/v6.0.0/management/aggregate-metrics);
+ |- which **commit** the instance runs, and once a day which endpoints it serves and which Roles anyone holds.
+ |
+ |The apps are watched too: see **The apps' monitoring endpoints** below.
+ |
+ |Each log line becomes a **signature**: the same message with its ids, numbers, quoted values, emails and IP addresses taken out. So one problem counts once, however many users hit it. Signatures are counted per 15-minute period, so Sentinel can tell a new or rising problem from an old, steady one. The log cache keeps only the newest 1000 lines per level: if more arrive between two reads, the read is marked as possibly missing lines.
+ |
+ |## The apps' monitoring endpoints
+ |
+ |API Explorer II, the API Manager, the Portal and OBP-OIDC each serve their own log cache and Telemetry, in the same shape as OBP-API's:
+ |
+ || App | Log cache | Telemetry |
+ ||---|---|---|
+ || API Explorer II | GET /api/monitoring/log-cache/LEVEL | GET /api/monitoring/telemetry |
+ || API Manager | GET /monitoring/log-cache/LEVEL | GET /monitoring/telemetry |
+ || Portal | GET /monitoring/log-cache/LEVEL | GET /monitoring/telemetry |
+ || OBP-OIDC | GET /monitoring/log-cache/LEVEL | GET /monitoring/telemetry |
+ |
+ |LEVEL is a log level (error, warning, info, debug, trace) or `all`. Each app lists these endpoints on its `/status` page.
+ |
+ |They are guarded by the same Roles as OBP-API's own endpoints: CanGetSystemLogCacheLEVEL (or CanGetSystemLogCacheAll; `all` needs CanGetSystemLogCacheAll) and CanGetTelemetry. The app does not decide this itself: it passes the caller's token to OBP-API (GET /obp/v7.0.0/banks/SYS/my/roles/ROLE) and answers only if OBP-API says the Role is held. A call with no token gets 401, and a call without the Role gets 403.
+ |
+ |Sentinel finds the apps in OBP-API's app directory (GET /obp/v6.0.0/app-directory: public_obp_api_explorer_url, public_obp_api_manager_url, public_obp_portal_url, public_obp_oidc_url) and watches each like a small instance of its own, with its own findings, using its OBP-API instance's token. The analyst then reads the app's own source. An app has no aggregate metrics and no source review.
+ |
+ |## 2. The analyst
+ |
+ |Every few hours, once Sentinel has watched for long enough, Claude reads a summary of what was collected (the last hours compared with the hours before) and reads the source code to find the cause of what stands out. Each problem it is confident about becomes a **finding**, scored by:
+ |
+ |- **Impact**: how much it hurts users or the service;
+ |- **Confidence**: how sure the analyst is about the cause;
+ |- **Effort**: how much work the fix is;
+ |- **Trend**: new, rising, stable or falling;
+ |- **Priority**: these combined, what is worth doing first.
+ |
+ |## 3. Source review
+ |
+ |Besides following the logs, Sentinel can read the code of the endpoints each instance serves, looking for security problems such as SQL injection or missing authorisation checks. Go and Stop turn it on and off. It starts with the endpoints anyone can call without a login, then those that need a login, then those whose Role someone holds. It remembers what it read: an endpoint is read again only when its code changes.
+ |
+ |## 4. Suggestions
+ |
+ |A finding is suggested only if Sentinel has watched for long enough (4 hours by default), its priority is high enough, it was seen in more than one 15-minute period, and it was not already suggested, acted on, dismissed or fixed. At most 3 suggestions are made at a time, and at most 10 a week. When nothing qualifies, Sentinel says so rather than inventing work.
+ |
+ |## Using the page
+ |
+ |The tabs above the list sort the findings: **To review** (suggested and waiting for an answer), **Not yet suggested**, **Snoozed** and **Done**. Below them, three sections show Sentinel at work: monitoring activity, the Analyst (Run analysis now starts a run without waiting) and the Source review. With several instances, the first row of tabs chooses an OBP-API instance and the second row its apps.
+ |
+ |Each finding shows its evidence, why it happens, where in the code, and the suggested change. The copy button next to its title copies it all as text, ready to give to a coding agent.
+ |
+ |- **Trace history** lists the commits that changed the lines the finding names, and Claude says which commit introduced the problem (at most $$0.30).
+ |- **Look again: still an issue?** has Claude read the code the instance runs now, the commits since the finding was made, and whether its log messages are still seen. It answers in one line: still an issue, fixed (and by which commit), or could not tell. It takes a few minutes and costs at most $$0.50. While it works, the page shows how long it has been going and the command it is running. It does not change the finding's status.
+ |
+ |Then answer, with a comment for the analyst if you like:
+ |
+ |- **Accept**: worth doing.
+ |- **Later**: snooze it.
+ |- **Some action taken**: someone tried to fix it; the analyst checks whether the problem went away.
+ |- **Should be fixed now**: fixed; if it comes back, it will be suggested again.
+ |- **Dismiss, never suggest again**: not useful; its log messages are also left out of future summaries.
+ |
+ |These answers teach the analyst what is worth suggesting.
+ |
+ |## The Secured check
+ |
+ |Once an hour Sentinel calls the endpoints it reads (log cache, Telemetry and, for OBP-API, aggregate metrics) without a token. They must refuse. The header shows **Secured** when every one refused (401 or 403), **Open** when at least one answered with data (fix this first: anyone can read those logs), and **Could not tell** when some gave no answer or an unexpected one, such as 404.
+ |
+ |## What Sentinel needs
+ |
+ |Its Consumer is a Platform App: an administrator marks it, Sentinel declares the Scopes it needs, and the administrator grants them. They are CanGetSystemLogCacheAll (or one per level), CanGetTelemetry and the Role for aggregate metrics. When something stops Sentinel from watching an instance, its page lists what to fix, most blocking first, with what OBP-API answered.
+ |
+ |## What Sentinel never does
+ |
+ |- It only reads OBP-API: it does not change settings or log levels, edit code, commit or open pull requests.
+ |- It reads source code with git only, and never changes the checkout.
+ |- Log text is treated as data: instructions inside a log line are never followed.
+ |- Its page has no login: keep it on 127.0.0.1, or behind a proxy that asks for one.
+ |""".stripMargin)
+
+
glossaryItems += GlossaryItem(
title = "Platform Apps",
description =
diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala
index 399eeec3dd..9603c8aec8 100644
--- a/obp-api/src/main/scala/code/api/util/NewStyle.scala
+++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala
@@ -4715,13 +4715,31 @@ object NewStyle extends MdcLoggable{
}
}
+ /**
+ * This checks that a User Auth Context Update may be requested at the bank bankId. A request emails a challenge
+ * to a Customer of that bank, so it must come from an App the bank has granted the Scope
+ * CanCreateUserAuthContextUpdate, or from a User it has granted the Role CanCreateUserAuthContextUpdate, in
+ * both cases at that bank. Either is enough. The check applies whatever the instance's scope settings are.
+ */
+ def checkCanRequestUserAuthContextUpdate(bankId: BankId, user: User, callContext: Option[CallContext]): Future[Box[Unit]] = {
+ val role = ApiRole.canCreateUserAuthContextUpdate
+ Helper.booleanToFuture(
+ s"$ConsumerHasMissingRoles $role at bank ${bankId.value}, and the User does not have the Role $role there either.",
+ failCode = 403, cc = callContext) {
+ APIUtil.hasScope(bankId.value, APIUtil.getConsumerPrimaryKey(callContext), role) ||
+ APIUtil.hasEntitlement(bankId.value, user.userId, role)
+ }
+ }
+
/**
* This answers the challenge of a User Auth Context Update on behalf of the User who requested it, and is
* shared by the v3.1.0 and v5.0.0 answer endpoints.
*
- * The update must belong to the answering User. Otherwise any logged-in User could answer, or simply use up,
- * the challenge of someone else's update, so an update of another User is reported exactly like one that does
- * not exist. Only an ACCEPTED answer (the right one-time password) has any effect: it creates the User Auth
+ * The update must belong to the answering User and have been requested at the bank bankId. Otherwise any
+ * logged-in User could answer, or simply use up, the challenge of someone else's update, and a User who
+ * answered correctly for a CUSTOMER_NUMBER at one bank could be linked to the Customer with the same number at
+ * another bank. An update of another User or another bank is reported exactly like one that does not exist,
+ * and is checked before the answer, so its challenge is not used up. Only an ACCEPTED answer (the right one-time password) has any effect: it creates the User Auth
* Context and, for the key CUSTOMER_NUMBER, links the User to the Customer with that number at the bank. A
* REJECTED answer changes nothing, so a wrong answer can never link a User to someone else's Customer.
*/
@@ -4732,11 +4750,10 @@ object NewStyle extends MdcLoggable{
answeringUser: User,
callContext: Option[CallContext]
): OBPReturnType[UserAuthContextUpdate] = {
- val updatesOfTheAnsweringUser = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend
- .getUserAuthContextUpdatesBox(answeringUser.userId).toList.flatten
for {
_ <- Helper.booleanToFuture(UserAuthContextUpdateNotFound, cc = callContext) {
- updatesOfTheAnsweringUser.exists(_.userAuthContextUpdateId == authContextUpdateId)
+ UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend
+ .getUserAuthContextUpdateOfUserAtBank(authContextUpdateId, answeringUser.userId, bankId.value).isDefined
}
(userAuthContextUpdate, callContext) <- checkAnswer(authContextUpdateId, answer, callContext)
callContext <- if (userAuthContextUpdate.status == UserAuthContextUpdateStatus.ACCEPTED.toString) {
diff --git a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala
index eb7a76e5a0..f47e46a775 100644
--- a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala
@@ -54,6 +54,13 @@ object WriteMetricUtil extends MdcLoggable {
operationIds.contains(operationId.getOrElse("None"))
}
+ /**
+ * Called for every endpoint call, whether or not write_metrics is on: it is Redis-only and
+ * throttled (see code.metrics.ConsumerLastCall), so it costs next to nothing.
+ */
+ def recordConsumerLastCall(consumerId: Option[String], startTime: Option[Date]): Unit =
+ code.metrics.ConsumerLastCall.record(consumerId.orNull, startTime.getOrElse(new Date()))
+
def writeEndpointMetric(responseBody: Any, callContext: Option[CallContextLight]): Unit = callContext match {
case Some(cc) if code.metrics.MetricsProps.writeMetrics =>
persistAndPublishMetric(responseBody, cc)
diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala
index 5bae77aeea..930a49b890 100644
--- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala
+++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala
@@ -221,14 +221,15 @@ object Http4sRequestAttributes {
// Not fired as a background fiber: response waits for metric to be written,
// matching v6 behaviour and avoiding unbounded concurrent H2 write contention.
private def recordMetric(responseBody: Any, response: Response[IO])(implicit cc: CallContext): IO[Unit] =
- if (!code.metrics.MetricsProps.writeMetrics) IO.unit
+ IO(WriteMetricUtil.recordConsumerLastCall(cc.consumer.map(_.consumerId.get).toOption, cc.startTime)) >>
+ (if (!code.metrics.MetricsProps.writeMetrics) IO.unit
else IO.blocking {
val endTime = new Date()
val duration = cc.startTime.map(s => endTime.getTime - s.getTime).getOrElse(-1L)
val ccLight = cc.copy(httpCode = Some(response.status.code), endTime = Some(endTime)).toLight
logger.info(s"Endpoint (${cc.verb}) ${cc.url} returned ${response.status.code}, took $duration Milliseconds")
WriteMetricUtil.writeEndpointMetric(responseBody, Some(ccLight))
- }
+ })
/**
* This records the endpoint metric for a response that was built outside these helpers, such as
@@ -239,7 +240,8 @@ object Http4sRequestAttributes {
* in-memory strings, so reading it here does not consume what the client receives.
*/
def recordMetricFor(response: Response[IO])(implicit cc: CallContext): IO[Response[IO]] =
- if (!code.metrics.MetricsProps.writeMetrics) IO.pure(response)
+ if (!code.metrics.MetricsProps.writeMetrics)
+ IO(WriteMetricUtil.recordConsumerLastCall(cc.consumer.map(_.consumerId.get).toOption, cc.startTime)).as(response)
else response.bodyText.compile.string.flatMap { text =>
val body: Any = scala.util.Try(com.openbankproject.commons.util.JsonAliases.parse(text)).getOrElse(text)
recordMetric(body, response)
diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
index 59ebb0b506..b9dd1e1e7a 100644
--- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
+++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
@@ -3863,11 +3863,7 @@ object Http4s310 {
case req @ POST -> `prefixPath` / "banks" / _ / "users" / "current" / "auth-context-updates" / scaMethod =>
EndpointHelpers.withUserAndBankAndBodyCreated[PostUserAuthContextJson, Any](req) { (user, bank, postedData, cc) =>
for {
- _ <- code.util.Helper.booleanToFuture(
- failMsg = ConsumerHasMissingRoles + ApiRole.canCreateUserAuthContextUpdate,
- cc = Some(cc)) {
- APIUtil.checkScope(bank.bankId.value, APIUtil.getConsumerPrimaryKey(Some(cc)), ApiRole.canCreateUserAuthContextUpdate)
- }
+ _ <- NewStyle.function.checkCanRequestUserAuthContextUpdate(bank.bankId, user, Some(cc))
_ <- code.util.Helper.booleanToFuture(UserAuthContextUpdateRequestAllowedScaMethods, cc = Some(cc)) {
List(StrongCustomerAuthentication.SMS.toString, StrongCustomerAuthentication.EMAIL.toString).contains(scaMethod)
}
@@ -3886,13 +3882,19 @@ object Http4s310 {
s"""Create User Auth Context Update Request.
|${userAuthenticationMessage(true)}
|
- |A One Time Password (OTP) (AKA security challenge) is sent Out of Band (OOB) to the User via the transport defined in SCA_METHOD
+ |A User uses this to prove that they are a Customer of the bank BANK_ID, for example by naming their CUSTOMER_NUMBER.
+ |A One Time Password (OTP) (AKA security challenge) is sent Out of Band (OOB) to the Customer's own contact details,
+ |via the transport defined in SCA_METHOD, and the User then answers it with Answer User Auth Context Update Challenge.
|SCA_METHOD is typically "SMS" or "EMAIL". "EMAIL" is used for testing purposes.
|
+ |Because this sends a message to a Customer of the bank, the request must come either from an App (Consumer) that has
+ |the Scope ${canCreateUserAuthContextUpdate} at BANK_ID, or from a User that has the Role ${canCreateUserAuthContextUpdate} at BANK_ID.
+ |Either one is enough. A Scope is granted with Create Scope for a Consumer (POST /consumers/CONSUMER_ID/scopes).
+ |
|""",
postUserAuthContextJson,
userAuthContextUpdateJson,
- List(AuthenticatedUserIsRequired, InvalidJsonFormat, CreateUserAuthContextError, UnknownError),
+ List(AuthenticatedUserIsRequired, InvalidJsonFormat, ConsumerHasMissingRoles, CreateUserAuthContextError, UnknownError),
List(apiTagUser),
None,
http4sPartialFunction = Some(createUserAuthContextUpdateRequest)
@@ -3919,10 +3921,17 @@ object Http4s310 {
"Answer Auth Context Update Challenge",
s"""
|Answer Auth Context Update Challenge.
+ |
+ |Only the User who requested the update can answer it, and only at the bank (BANK_ID) it was requested at.
+ |An update of another User or another bank is reported as not found, and its challenge is not used up.
+ |
+ |A right answer sets the status to ACCEPTED and adds the User Auth Context. For the key CUSTOMER_NUMBER it also
+ |links the User to the Customer with that number at the bank. A wrong answer sets the status to REJECTED and
+ |changes nothing else.
|""",
PostUserAuthContextUpdateJsonV310(answer = "12345678"),
userAuthContextUpdateJson,
- List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, InvalidConnectorResponse, UnknownError),
+ List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat, UserAuthContextUpdateNotFound, InvalidConnectorResponse, UnknownError),
apiTagUser :: Nil,
None,
http4sPartialFunction = Some(answerUserAuthContextUpdateChallenge)
diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
index 5e5a61d197..c497bf43cd 100644
--- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
+++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
@@ -3520,6 +3520,10 @@ object Http4s400 {
|
|Money goes into the Counterparty in the request body.
|
+ |The paying account is the account of the card in the request body. The User needs access to the owner view of
+ |that account with permission to add transaction requests (or the Role CanCreateAnyTransactionRequest at its
+ |bank), and the payee must be a counterparty of that account; a counterparty of any other account is not found.
+ |
|$transactionRequestGeneralText
|
""".stripMargin,
@@ -3533,6 +3537,7 @@ object Http4s400 {
AccountNotFound,
$BankAccountNotFound,
InsufficientAuthorisationToCreateTransactionRequest,
+ CounterpartyNotFoundByCounterpartyId,
InvalidTransactionRequestType,
InvalidJsonFormat,
InvalidNumber,
diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
index 9e94a8e51f..84911a0d98 100644
--- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
+++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
@@ -778,9 +778,7 @@ object Http4s500 {
val bankId = BankId(bankIdStr)
for {
user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired))
- _ <- Helper.booleanToFuture(ConsumerHasMissingRoles + CanCreateUserAuthContextUpdate, cc = Some(cc)) {
- checkScope(bankId.value, getConsumerPrimaryKey(Some(cc)), ApiRole.canCreateUserAuthContextUpdate)
- }
+ _ <- NewStyle.function.checkCanRequestUserAuthContextUpdate(bankId, user, Some(cc))
_ <- Helper.booleanToFuture(UserAuthContextUpdateRequestAllowedScaMethods, cc = Some(cc)) {
List(StrongCustomerAuthentication.SMS.toString(), StrongCustomerAuthentication.EMAIL.toString()).contains(scaMethod)
}
@@ -803,13 +801,19 @@ object Http4s500 {
s"""Create User Auth Context Update Request.
|${userAuthenticationMessage(true)}
|
- |A One Time Password (OTP) (AKA security challenge) is sent Out of Band (OOB) to the User via the transport defined in SCA_METHOD
+ |A User uses this to prove that they are a Customer of the bank BANK_ID, for example by naming their CUSTOMER_NUMBER.
+ |A One Time Password (OTP) (AKA security challenge) is sent Out of Band (OOB) to the Customer's own contact details,
+ |via the transport defined in SCA_METHOD, and the User then answers it with Answer User Auth Context Update Challenge.
|SCA_METHOD is typically "SMS" or "EMAIL". "EMAIL" is used for testing purposes.
|
+ |Because this sends a message to a Customer of the bank, the request must come either from an App (Consumer) that has
+ |the Scope ${canCreateUserAuthContextUpdate} at BANK_ID, or from a User that has the Role ${canCreateUserAuthContextUpdate} at BANK_ID.
+ |Either one is enough. A Scope is granted with Create Scope for a Consumer (POST /consumers/CONSUMER_ID/scopes).
+ |
|""",
postUserAuthContextJson,
userAuthContextUpdateJsonV500,
- List(AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, CreateUserAuthContextError, UnknownError),
+ List(AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, ConsumerHasMissingRoles, CreateUserAuthContextError, UnknownError),
List(apiTagUser),
None,
http4sPartialFunction = Some(createUserAuthContextUpdateRequest)
@@ -843,10 +847,17 @@ object Http4s500 {
"Answer User Auth Context Update Challenge",
s"""
|Answer User Auth Context Update Challenge.
+ |
+ |Only the User who requested the update can answer it, and only at the bank (BANK_ID) it was requested at.
+ |An update of another User or another bank is reported as not found, and its challenge is not used up.
+ |
+ |A right answer sets the status to ACCEPTED and adds the User Auth Context. For the key CUSTOMER_NUMBER it also
+ |links the User to the Customer with that number at the bank. A wrong answer sets the status to REJECTED and
+ |changes nothing else.
|""",
postUserAuthContextUpdateJsonV310,
userAuthContextUpdateJsonV500,
- List(AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, InvalidConnectorResponse, UnknownError),
+ List(AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UserAuthContextUpdateNotFound, InvalidConnectorResponse, UnknownError),
apiTagUser :: Nil,
None,
http4sPartialFunction = Some(answerUserAuthContextUpdateChallenge)
diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
index a71190b215..0197684e62 100644
--- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
+++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
@@ -147,8 +147,8 @@ object Http4s510 {
ConsentStatus.REVOKED.toString
)
- // Used by lifted consumer-management endpoint descriptions.
- private def consumerDisabledText(): String = {
+ // Used by lifted consumer-management endpoint descriptions (and their v7.0.0 versions).
+ def consumerDisabledText(): String = {
if (APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) == false) {
"Please note: Your consumer may be disabled as a result of this action."
} else {
@@ -679,7 +679,7 @@ object Http4s510 {
}
// For the Consumer docs that take a redirect_url: the rules it must meet and this instance's host list.
- private val redirectUrlRulesText =
+ val redirectUrlRulesText =
s"""**redirect_url** must meet the redirect URL rules (https; http only for localhost; or an app scheme in
|reverse-domain form; no wildcard, user information or fragment). ${code.api.util.RedirectUrlValidation.allowedHostsDescription}
|See ${Glossary.getGlossaryItemLink("Redirect URL")}
@@ -841,6 +841,8 @@ object Http4s510 {
"/management/consumers", "Get Consumers",
s"""Get the all Consumers.
|
+ |`consumer_key` is returned as `${APIUtil.RedactedConsumerKey}`: the key is a credential. Get Consumers v7.0.0 leaves the field out.
+ |
|${userAuthenticationMessage(true)}
|
|${urlParametersDocument(true, true)}
@@ -2904,7 +2906,7 @@ object Http4s510 {
isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", defaultValue = false)),
redirectURL = Some(postJson.redirect_url),
callContext = Some(cc))
- } yield JSONFactory510.createConsumerJSON(updatedConsumer)
+ } yield JSONFactory510.createRedactedConsumerJSON(updatedConsumer)
}
}
resourceDocs += ResourceDoc(
@@ -2914,6 +2916,8 @@ object Http4s510 {
"/management/consumers/CONSUMER_ID/consumer/redirect_url",
"Update Consumer RedirectURL",
s"""Update an existing redirectUrl for a Consumer specified by CONSUMER_ID.
+ |
+ |`consumer_key` is returned as `${APIUtil.RedactedConsumerKey}`: the key is a credential. The v7.0.0 version leaves the field out.
|
| ${consumerDisabledText()}
|
@@ -2942,7 +2946,7 @@ object Http4s510 {
consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc))
updatedConsumer <- NewStyle.function.updateConsumer(
id = consumer.id.get, logoURL = Some(postJson.logo_url), callContext = Some(cc))
- } yield JSONFactory510.createConsumerJSON(updatedConsumer)
+ } yield JSONFactory510.createRedactedConsumerJSON(updatedConsumer)
}
}
resourceDocs += ResourceDoc(
@@ -2952,6 +2956,8 @@ object Http4s510 {
"/management/consumers/CONSUMER_ID/consumer/logo_url",
"Update Consumer LogoURL",
s"""Update an existing logoURL for a Consumer specified by CONSUMER_ID.
+ |
+ |`consumer_key` is returned as `${APIUtil.RedactedConsumerKey}`: the key is a credential. The v7.0.0 version leaves the field out.
|
| ${consumerDisabledText()}
|
@@ -2979,7 +2985,7 @@ object Http4s510 {
consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc))
updatedConsumer <- NewStyle.function.updateConsumer(
id = consumer.id.get, certificate = Some(postJson.certificate), callContext = Some(cc))
- } yield JSONFactory510.createConsumerJSON(updatedConsumer)
+ } yield JSONFactory510.createRedactedConsumerJSON(updatedConsumer)
}
}
resourceDocs += ResourceDoc(
@@ -2989,6 +2995,8 @@ object Http4s510 {
"/management/consumers/CONSUMER_ID/consumer/certificate",
"Update Consumer Certificate",
s"""Update a Certificate for a Consumer specified by CONSUMER_ID.
+ |
+ |`consumer_key` is returned as `${APIUtil.RedactedConsumerKey}`: the key is a credential. The v7.0.0 version leaves the field out.
|
| ${consumerDisabledText()}
|
@@ -3016,7 +3024,7 @@ object Http4s510 {
consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc))
updatedConsumer <- NewStyle.function.updateConsumer(
id = consumer.id.get, name = Some(postJson.app_name), callContext = Some(cc))
- } yield JSONFactory510.createConsumerJSON(updatedConsumer)
+ } yield JSONFactory510.createRedactedConsumerJSON(updatedConsumer)
}
}
resourceDocs += ResourceDoc(
@@ -3026,6 +3034,8 @@ object Http4s510 {
"/management/consumers/CONSUMER_ID/consumer/name",
"Update Consumer Name",
s"""Update an existing name for a Consumer specified by CONSUMER_ID.
+ |
+ |`consumer_key` is returned as `${APIUtil.RedactedConsumerKey}`: the key is a credential. The v7.0.0 version leaves the field out.
|
| ${consumerDisabledText()}
|
diff --git a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala
index ecac26ed01..9c23969873 100644
--- a/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala
+++ b/obp-api/src/main/scala/code/api/v5_1_0/JSONFactory5.1.0.scala
@@ -1337,8 +1337,11 @@ object JSONFactory510 extends CustomJsonFormats with MdcLoggable {
)
}
+ def createRedactedConsumerJSON(c: Consumer): ConsumerJsonV510 =
+ createConsumerJSON(c).copy(consumer_key = code.api.util.APIUtil.redactConsumerKey(c.key.get))
+
def createConsumersJson(consumers:List[Consumer]) = {
- ConsumersJsonV510(consumers.map(createConsumerJSON(_,None)))
+ ConsumersJsonV510(consumers.map(createRedactedConsumerJSON))
}
def createAgentJson(agent: Agent, bankAccount: BankAccount): AgentJsonV510 = {
diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
index b25db23e42..6ee653f302 100644
--- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
+++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
@@ -5017,48 +5017,11 @@ object Http4s600 {
}
}
- // DirectLogin header parser — mirrors the parsing in code.api.directlogin.DirectLogin.getAllParameters
- // but reads from CallContext.requestHeaders (populated by the http4s context builder) instead of
- // Lift's thread-local S.request.
- private def parseDirectLoginParams(cc: CallContext): Map[String, String] = {
- def find(name: String): Option[String] = code.api.util.RequestHeadersUtil.find(cc.requestHeaders, name)
- .flatMap(_.values.headOption)
- val directLoginHeader = find("DirectLogin")
- val authHeader = find("Authorization")
- val raw = directLoginHeader
- .orElse(authHeader.filter(h => h.startsWith("DirectLogin") || h.contains("DirectLogin")))
- .getOrElse("")
- val cleaned = raw.stripPrefix("DirectLogin").split(",").map(_.trim).toList
- val keys = Set("consumer_key", "token", "username", "password")
- cleaned.flatMap { entry =>
- if (entry.contains("=")) {
- val s = entry.split("=", 2)
- val v = s(1).replaceAll("^\"|\"$", "")
- if (keys.contains(s(0)) && v.nonEmpty) Some(s(0) -> v) else None
- } else None
- }.toMap
- }
-
+ // The token flow, including the allow_direct_login check, is shared with the unversioned route.
lazy val directLoginEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> `prefixPath` / "my" / "logins" / "direct" =>
EndpointHelpers.executeFutureCreated(req) {
- implicit val cc: CallContext = req.callContext
- // If the parser found nothing usable, fall back to a single "error" key so that
- // validatorFutureWithParams returns the MissingDirectLoginHeader message (preserves
- // Lift's getAllParameters behaviour for the no-header case).
- val parsed = parseDirectLoginParams(cc)
- val params =
- if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader)
- else parsed
- for {
- triple <- code.api.DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params)
- (httpCode, message, dlParams) = triple
- tokenTriple = code.api.DirectLogin.createTokenCommonPart(httpCode, message, dlParams)
- _ <- Future(code.api.DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3))
- } yield {
- if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2)
- else unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1)
- }
+ code.api.DirectLoginRoutes.issueToken(req.callContext)
}
}
@@ -7133,7 +7096,7 @@ object Http4s600 {
|
|This endpoint returns all consumer fields including:
|- Basic info: consumer_id, app_name, app_type, description, developer_email, company
- |- OAuth: consumer_key, redirect_url
+ |- OAuth: redirect_url
|- Status: enabled, created
|- Certificate: certificate_pem, certificate_info (subject, issuer, validity dates, PSD2 roles)
|- Branding: logo_url
@@ -7141,7 +7104,8 @@ object Http4s600 {
|- Rate limits: active_rate_limits showing current rate limiting configuration
|- Call counters: call_counters showing current API call usage from Redis
|
- |Note: consumer_secret is never returned for security reasons.
+ |Note: consumer_secret is never returned, and consumer_key is returned as `${APIUtil.RedactedConsumerKey}`:
+ |both are credentials. Get Consumer v7.0.0 leaves consumer_key out.
|
|${userAuthenticationMessage(true)}
|
@@ -13219,10 +13183,13 @@ object Http4s600 {
|
| DirectLogin: token=YOUR_TOKEN
|
+ |On this instance DirectLogin is ${if (APIUtil.getPropsAsBoolValue("allow_direct_login", true)) "enabled" else "disabled: this endpoint refuses every request with DirectLoginIsDisabled, without checking any credentials"}.
+ |
|""".stripMargin,
EmptyBody,
JSONFactory600.createTokenJSON("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczpcL1wvd3d3Lm9wZW5iYW5rcHJvamVjdC5jb20iLCJpYXQiOjE0NTU4OTQyNzYsImV4cCI6MTQ1NTg5Nzg3NiwiYXVkIjoib2JwLWFwaSIsInN1YiI6IjA2Zjc0YjUwLTA5OGYtNDYwNi1hOGNjLTBjNDc5MjAyNmI5ZCIsImNvbnN1bWVyX2tleSI6IjYwNGY3ZTAyNGQ5MWU2MzMwNGMzOGM0YzRmZjc0MjMwZGU5NDk4NTEwNjgxZWNjM2Q5MzViNWQ5MGEwOTI3ODciLCJyb2xlIjoiY2FuX2FjY2Vzc19hcGkifQ.f8xHvXP5fDxo5-LlfTj1OQS9oqHNZfFd7N-WkV2o4Cc"),
List(
+ DirectLoginIsDisabled,
InvalidDirectLoginParameters,
InvalidLoginCredentials,
InvalidConsumerCredentials,
diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala
index cd491e44a3..99f0cc9d8a 100644
--- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala
+++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala
@@ -1454,7 +1454,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
ConsumerJsonV600(
consumer_id = c.consumerId.get,
- consumer_key = c.key.get,
+ // v7.0.0 Get Consumer leaves the field out
+ consumer_key = code.api.util.APIUtil.redactConsumerKey(c.key.get),
app_name = c.name.get,
app_type = c.appType.toString(),
description = c.description.get,
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 7f290f2b01..c2380db454 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
@@ -820,6 +820,233 @@ object Http4s700 {
http4sPartialFunction = Some(getCurrentConsumerIdentity)
)
+ // Route: GET /obp/v7.0.0/management/consumers
+ // Same list as v5.1.0 Get Consumers without consumer_key: the key is a credential, the list references Consumers by consumer_id.
+ val getConsumers: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "consumers" =>
+ EndpointHelpers.executeFuture(req) {
+ implicit val cc: CallContext = req.callContext
+ for {
+ httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString)
+ (obpQueryParams, _) <- APIUtil.createQueriesByHttpParamsFuture(httpParams, Some(cc))
+ consumers <- code.consumer.Consumers.consumers.vend.getConsumersFuture(obpQueryParams, Some(cc))
+ } yield JSONFactory700.createConsumersJsonV700(consumers)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getConsumers),
+ "GET",
+ "/management/consumers",
+ "Get Consumers",
+ s"""Get all Consumers.
+ |
+ |The response has no `consumer_key`: the key is a credential. Refer to a Consumer by its `consumer_id`.
+ |
+ |${userAuthenticationMessage(true)}
+ |
+ |${urlParametersDocument(true, true)}
+ |
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700.consumersJsonV700Example,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ apiTagConsumer :: Nil,
+ Some(List(ApiRole.canGetConsumers)),
+ authMode = UserOrApplication,
+ http4sPartialFunction = Some(getConsumers)
+ )
+
+ // Route: GET /obp/v7.0.0/management/consumers/CONSUMER_ID
+ // v6.0.0 Get Consumer without consumer_key.
+ val getConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId =>
+ EndpointHelpers.executeFuture(req) {
+ implicit val cc: CallContext = req.callContext
+ for {
+ consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc))
+ callCounters <- Future(code.api.util.RateLimitingUtil.consumerRateLimitState(consumer.consumerId.get).toList)
+ date = new java.util.Date()
+ (activeRateLimit, rateLimitIds) <- code.api.util.RateLimitingUtil.getActiveRateLimitsWithIds(consumer.consumerId.get, date)
+ } yield JSONFactory700.createConsumerDetailsJsonV700(
+ JSONFactory600.createConsumerJsonV600(
+ consumer,
+ None,
+ JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(activeRateLimit, rateLimitIds, date),
+ JSONFactory600.createRedisCallCountersJson(callCounters)
+ )
+ )
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getConsumer),
+ "GET",
+ "/management/consumers/CONSUMER_ID",
+ "Get Consumer",
+ s"""Get the Consumer specified by CONSUMER_ID, with its active rate limits and current call counters.
+ |
+ |The response has no `consumer_key` and no `consumer_secret`: both are credentials.
+ |
+ |${userAuthenticationMessage(true)}
+ |
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700.consumerDetailsJsonV700Example,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConsumerNotFoundByConsumerId, UnknownError),
+ apiTagConsumer :: Nil,
+ Some(List(ApiRole.canGetConsumers)),
+ http4sPartialFunction = Some(getConsumer)
+ )
+
+ // The four Consumer field updates share everything but the field: read the body, find the Consumer,
+ // apply the change, answer without the key.
+ private def updateConsumerField[T: Manifest](req: Request[IO], consumerId: String)(
+ update: (T, code.model.Consumer, CallContext) => Future[code.model.Consumer]
+ ): IO[Response[IO]] =
+ EndpointHelpers.executeFuture(req) {
+ implicit val cc: CallContext = req.callContext
+ for {
+ postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) {
+ com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[T]
+ }
+ consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc))
+ updatedConsumer <- update(postJson, consumer, cc)
+ } yield JSONFactory700.createConsumerJsonV700(updatedConsumer)
+ }
+
+ private val updateConsumerNoKeyText = "The response has no `consumer_key`: the key is a credential."
+ private val Implementations5_1_0 = code.api.v5_1_0.Http4s510.Implementations5_1_0
+
+ // Route: PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/consumer/redirect_url
+ val updateConsumerRedirectURL: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "redirect_url" =>
+ updateConsumerField[code.api.v2_1_0.ConsumerRedirectUrlJSON](req, consumerId) { (postJson, consumer, cc) =>
+ implicit val callContext: CallContext = cc
+ for {
+ user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired))
+ _ <- Helper.booleanToFuture(UserNoPermissionUpdateConsumer, 400, Some(cc)) {
+ consumer.createdByUserId.equals(user.userId)
+ }
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postJson.redirect_url, Some(cc))
+ updatedConsumer <- NewStyle.function.updateConsumer(
+ id = consumer.id.get,
+ isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", defaultValue = false)),
+ redirectURL = Some(postJson.redirect_url),
+ callContext = Some(cc))
+ } yield updatedConsumer
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(updateConsumerRedirectURL),
+ "PUT",
+ "/management/consumers/CONSUMER_ID/consumer/redirect_url",
+ "Update Consumer RedirectURL",
+ s"""Update the redirect URL of the Consumer specified by CONSUMER_ID. Only the user who created the Consumer may change it.
+ |
+ |${Implementations5_1_0.consumerDisabledText()}
+ |
+ |$updateConsumerNoKeyText
+ |
+ |${Implementations5_1_0.redirectUrlRulesText}
+ |""".stripMargin,
+ consumerRedirectUrlJSON,
+ JSONFactory700.consumersJsonV700Example.consumers.head,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ConsumerNotFoundByConsumerId,
+ UserNoPermissionUpdateConsumer, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
+ apiTagConsumer :: Nil,
+ Some(List(ApiRole.canUpdateConsumerRedirectUrl)),
+ http4sPartialFunction = Some(updateConsumerRedirectURL)
+ )
+
+ // Route: PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/consumer/logo_url
+ val updateConsumerLogoURL: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "logo_url" =>
+ updateConsumerField[code.api.v5_1_0.ConsumerLogoUrlJson](req, consumerId) { (postJson, consumer, cc) =>
+ NewStyle.function.updateConsumer(id = consumer.id.get, logoURL = Some(postJson.logo_url), callContext = Some(cc))
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(updateConsumerLogoURL),
+ "PUT",
+ "/management/consumers/CONSUMER_ID/consumer/logo_url",
+ "Update Consumer LogoURL",
+ s"""Update the logo URL of the Consumer specified by CONSUMER_ID.
+ |
+ |${Implementations5_1_0.consumerDisabledText()}
+ |
+ |$updateConsumerNoKeyText
+ |""".stripMargin,
+ consumerLogoUrlJson,
+ JSONFactory700.consumersJsonV700Example.consumers.head,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ConsumerNotFoundByConsumerId, UnknownError),
+ apiTagConsumer :: Nil,
+ Some(List(ApiRole.canUpdateConsumerLogoUrl)),
+ http4sPartialFunction = Some(updateConsumerLogoURL)
+ )
+
+ // Route: PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/consumer/certificate
+ val updateConsumerCertificate: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "certificate" =>
+ updateConsumerField[code.api.v5_1_0.ConsumerCertificateJson](req, consumerId) { (postJson, consumer, cc) =>
+ NewStyle.function.updateConsumer(id = consumer.id.get, certificate = Some(postJson.certificate), callContext = Some(cc))
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(updateConsumerCertificate),
+ "PUT",
+ "/management/consumers/CONSUMER_ID/consumer/certificate",
+ "Update Consumer Certificate",
+ s"""Update the certificate of the Consumer specified by CONSUMER_ID.
+ |
+ |${Implementations5_1_0.consumerDisabledText()}
+ |
+ |$updateConsumerNoKeyText
+ |""".stripMargin,
+ consumerCertificateJson,
+ JSONFactory700.consumersJsonV700Example.consumers.head,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ConsumerNotFoundByConsumerId, UnknownError),
+ apiTagConsumer :: Nil,
+ Some(List(ApiRole.canUpdateConsumerCertificate)),
+ http4sPartialFunction = Some(updateConsumerCertificate)
+ )
+
+ // Route: PUT /obp/v7.0.0/management/consumers/CONSUMER_ID/consumer/name
+ val updateConsumerName: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "name" =>
+ updateConsumerField[code.api.v5_1_0.ConsumerNameJson](req, consumerId) { (postJson, consumer, cc) =>
+ NewStyle.function.updateConsumer(id = consumer.id.get, name = Some(postJson.app_name), callContext = Some(cc))
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(updateConsumerName),
+ "PUT",
+ "/management/consumers/CONSUMER_ID/consumer/name",
+ "Update Consumer Name",
+ s"""Update the name of the Consumer specified by CONSUMER_ID.
+ |
+ |${Implementations5_1_0.consumerDisabledText()}
+ |
+ |$updateConsumerNoKeyText
+ |""".stripMargin,
+ consumerNameJson,
+ JSONFactory700.consumersJsonV700Example.consumers.head,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ConsumerNotFoundByConsumerId, UnknownError),
+ apiTagConsumer :: Nil,
+ Some(List(ApiRole.canUpdateConsumerName)),
+ http4sPartialFunction = Some(updateConsumerName)
+ )
+
// Route: GET /obp/v7.0.0/consumers/current/scopes
// The Roles the calling Consumer holds as Scopes. No Role, like the identity above: a service may always
// learn what it has been granted, so its status page can say which Scopes it still needs.
@@ -1607,6 +1834,51 @@ object Http4s700 {
http4sPartialFunction = Some(getTopConsumers)
)
+ // ─── getConsumerLastCalls ─────────────────────────────────────────────────────
+ // Read from Redis only (code.metrics.ConsumerLastCall), so it costs one HGETALL whatever the
+ // metric table holds. Without Redis the list is empty rather than an error.
+
+ val getConsumerLastCalls: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "metrics" / "consumer-last-calls" =>
+ EndpointHelpers.withUser(req) { (_, _) =>
+ Future {
+ val lastCalls =
+ try code.metrics.ConsumerLastCall.getAll()
+ catch {
+ case scala.util.control.NonFatal(e) =>
+ logger.warn(s"getConsumerLastCalls says: Redis read failed: ${e.getMessage}")
+ code.metrics.ConsumerLastCall.LastCalls(None, Map.empty)
+ }
+ JSONFactory700.createConsumerLastCallsJsonV700(lastCalls)
+ }
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getConsumerLastCalls),
+ "GET",
+ "/management/metrics/consumer-last-calls",
+ "Get Consumer Last Calls",
+ s"""When each Consumer last called the API, most recent first.
+ |
+ |Kept in Redis, not in the metric table, so it is cheap to read for every Consumer at once. Each node records a
+ |Consumer at most once a minute, so `last_call_at` can be up to a minute early. The data is lost when Redis
+ |restarts: `tracked_since` is when recording began, and a Consumer that is not listed has made no call since then.
+ |`tracked_since` is null when nothing has been recorded yet, or Redis is not available.
+ |
+ |Recorded whether or not `write_metrics` is on.
+ |
+ |${userAuthenticationMessage(true)}
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700.consumerLastCallsJsonV700Example,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ apiTagMetric :: apiTagConsumer :: Nil,
+ Some(canReadMetrics :: Nil),
+ http4sPartialFunction = Some(getConsumerLastCalls)
+ )
+
// ── Trading Endpoints ──────────────────────────────────────────────────
// Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala
index 0a3cf45bfb..fa755bd2f0 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DomainApis.scala
@@ -134,6 +134,11 @@ object Http4s700DomainApis {
|
|BANK_ID is the space: a bank's id, or `SYS` for the system space. The Role is checked at that BANK_ID.
|
+ |`is_publicly_discoverable` says whether anyone, without logging in, may find the Domain API through
+ |`GET /obp/v7.0.0/banks/BANK_ID/domain-apis`. It is false when left out on create and keeps its current
+ |value when left out on update. It only controls that listing: the calls under the base path and the
+ |OpenAPI document are served the same either way.
+ |
|`base_path` is two to five segments of lowercase letters, digits, hyphens and dots, ending with the
|major version as `vN`, and must not overlap another Domain API's. `version` is the full semantic
|version, MAJOR.MINOR.PATCH, whose MAJOR is the N of the base path: a compatible change edits `version`
@@ -162,7 +167,8 @@ object Http4s700DomainApis {
val spaceId = space(bankIdInUrl)
for {
checked <- check(spaceId, body, None, cc)
- created <- Future(provider.create(spaceId, checked.basePath, checked.version, checked.title, checked.description, user.userId))
+ created <- Future(provider.create(spaceId, checked.basePath, checked.version, checked.title, checked.description,
+ body.is_publicly_discoverable.getOrElse(false), user.userId))
.map(fullOrFail(_, cc))
} yield JSONFactory700DomainApis.createDomainApiJson(created)
}
@@ -236,15 +242,51 @@ object Http4s700DomainApis {
http4sPartialFunction = Some(getDomainApi)
).allowSystemSpace()
+ // Route: GET /obp/v7.0.0/banks/BANK_ID/domain-apis (public)
+ lazy val getPubliclyDiscoverableDomainApis: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "banks" / bankIdInUrl / "domain-apis" =>
+ EndpointHelpers.executeAndRespond(req) { cc =>
+ Future(provider.getPubliclyDiscoverable(space(bankIdInUrl))).map(box =>
+ PublicDomainApisJsonV700(fullOrFail(box, cc).map(JSONFactory700DomainApis.createPublicDomainApiJson)))
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getPubliclyDiscoverableDomainApis),
+ "GET",
+ "/banks/BANK_ID/domain-apis",
+ "Get Publicly Discoverable Domain APIs",
+ s"""The Domain APIs of a space whose owners made them publicly discoverable (`is_publicly_discoverable`),
+ |so that an app or a person can find where a space's API is published and its OpenAPI document without
+ |logging in. A Domain API that is not publicly discoverable is left out, though its base path still serves.
+ |
+ |Each one gives its base path, version, title and description, its `url` (this instance's host followed by
+ |the base path) and its `openapi_url`; `openapi.json` beside it is the same document as JSON.
+ |
+ |BANK_ID is the space: a bank's id, or `SYS` for the system space.
+ |
+ |No Authentication is Required.
+ |
+ |For more information see ${Glossary.getGlossaryItemLink("Domain APIs")}""".stripMargin,
+ EmptyBody,
+ JSONFactory700DomainApis.publicDomainApisJsonV700Example,
+ List($BankNotFound, UnknownError),
+ apiTagDynamic :: apiTagApi :: Nil,
+ None,
+ http4sPartialFunction = Some(getPubliclyDiscoverableDomainApis)
+ ).allowSystemSpace()
+
// Route: PUT /obp/v7.0.0/management/banks/BANK_ID/domain-apis/DOMAIN_API_ID
lazy val updateDomainApi: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdInUrl / "domain-apis" / domainApiId =>
EndpointHelpers.withUserAndBody[PostDomainApiJsonV700, DomainApiJsonV700](req) { (_, body, cc) =>
val spaceId = space(bankIdInUrl)
for {
- _ <- found(spaceId, domainApiId, cc)
+ existing <- found(spaceId, domainApiId, cc)
checked <- check(spaceId, body, Some(domainApiId), cc)
- updated <- Future(provider.update(spaceId, domainApiId, checked.basePath, checked.version, checked.title, checked.description))
+ updated <- Future(provider.update(spaceId, domainApiId, checked.basePath, checked.version, checked.title, checked.description,
+ body.is_publicly_discoverable.getOrElse(existing.isPubliclyDiscoverable)))
.map(fullOrFail(_, cc))
} yield JSONFactory700DomainApis.createDomainApiJson(updated)
}
@@ -256,7 +298,7 @@ object Http4s700DomainApis {
"PUT",
"/management/banks/BANK_ID/domain-apis/DOMAIN_API_ID",
"Update Domain API",
- s"""Change a Domain API: its base path, version, title or description. The same checks as on create apply.
+ s"""Change a Domain API: its base path, version, title, description or whether it is publicly discoverable. The same checks as on create apply.
|Changing the base path moves every published URL, so it is a breaking change for its clients; a
|compatible change only edits `version`.
|
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
index c96123d184..accf19115f 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
@@ -2242,6 +2242,104 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
consumer_name = "OBP Portal"
)
+ /** A Consumer in the management list. No consumer_key: the key is a credential, a Consumer is referenced by consumer_id. */
+ case class ConsumerJsonV700(
+ consumer_id: String,
+ app_name: String,
+ app_type: String,
+ description: String,
+ developer_email: String,
+ company: String,
+ redirect_url: String,
+ certificate_pem: String,
+ created_by_user: code.api.v2_1_0.ResourceUserJSON,
+ enabled: Boolean,
+ created: Date,
+ logo_url: Option[String]
+ )
+
+ case class ConsumersJsonV700(consumers: List[ConsumerJsonV700])
+
+ def createConsumerJsonV700(consumer: code.model.Consumer): ConsumerJsonV700 = {
+ val c = code.api.v5_1_0.JSONFactory510.createConsumerJSON(consumer)
+ ConsumerJsonV700(
+ consumer_id = c.consumer_id,
+ app_name = c.app_name,
+ app_type = c.app_type,
+ description = c.description,
+ developer_email = c.developer_email,
+ company = c.company,
+ redirect_url = c.redirect_url,
+ certificate_pem = c.certificate_pem,
+ created_by_user = c.created_by_user,
+ enabled = c.enabled,
+ created = c.created,
+ logo_url = c.logo_url
+ )
+ }
+
+ def createConsumersJsonV700(consumers: List[code.model.Consumer]): ConsumersJsonV700 =
+ ConsumersJsonV700(consumers.map(createConsumerJsonV700))
+
+ lazy val consumersJsonV700Example: ConsumersJsonV700 = {
+ val c = code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.consumerJsonV510
+ ConsumersJsonV700(List(ConsumerJsonV700(
+ consumer_id = c.consumer_id,
+ app_name = c.app_name,
+ app_type = c.app_type,
+ description = c.description,
+ developer_email = c.developer_email,
+ company = c.company,
+ redirect_url = c.redirect_url,
+ certificate_pem = c.certificate_pem,
+ created_by_user = c.created_by_user,
+ enabled = c.enabled,
+ created = c.created,
+ logo_url = c.logo_url
+ )))
+ }
+
+ /** One Consumer with its rate limits and call counters. No consumer_key: the key is a credential. */
+ case class ConsumerDetailsJsonV700(
+ consumer_id: String,
+ app_name: String,
+ app_type: String,
+ description: String,
+ developer_email: String,
+ company: String,
+ redirect_url: String,
+ certificate_pem: String,
+ certificate_info: Option[code.api.v5_1_0.CertificateInfoJsonV510],
+ created_by_user: code.api.v2_1_0.ResourceUserJSON,
+ enabled: Boolean,
+ created: Date,
+ logo_url: Option[String],
+ active_rate_limits: code.api.v6_0_0.ActiveRateLimitsJsonV600,
+ call_counters: code.api.v6_0_0.RedisCallCountersJsonV600
+ )
+
+ def createConsumerDetailsJsonV700(c: code.api.v6_0_0.ConsumerJsonV600): ConsumerDetailsJsonV700 =
+ ConsumerDetailsJsonV700(
+ consumer_id = c.consumer_id,
+ app_name = c.app_name,
+ app_type = c.app_type,
+ description = c.description,
+ developer_email = c.developer_email,
+ company = c.company,
+ redirect_url = c.redirect_url,
+ certificate_pem = c.certificate_pem,
+ certificate_info = c.certificate_info,
+ created_by_user = c.created_by_user,
+ enabled = c.enabled,
+ created = c.created,
+ logo_url = c.logo_url,
+ active_rate_limits = c.active_rate_limits,
+ call_counters = c.call_counters
+ )
+
+ lazy val consumerDetailsJsonV700Example: ConsumerDetailsJsonV700 =
+ createConsumerDetailsJsonV700(code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.consumerJsonV600)
+
/** One Role the calling Consumer holds as a Scope, and where: a bank id, SYS, or "" for a system Role. */
case class CurrentConsumerScopeJsonV700(
role_name: String,
@@ -2470,6 +2568,30 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
)
)
+ // ─── Consumer Last Calls (v7.0.0) ───
+ // From Redis (code.metrics.ConsumerLastCall). tracked_since is when recording began; a Consumer
+ // not listed has made no call since then.
+ case class ConsumerLastCallJsonV700(consumer_id: String, last_call_at: java.util.Date)
+ case class ConsumerLastCallsJsonV700(
+ tracked_since: Option[java.util.Date],
+ consumer_last_calls: List[ConsumerLastCallJsonV700]
+ )
+
+ def createConsumerLastCallsJsonV700(lastCalls: code.metrics.ConsumerLastCall.LastCalls): ConsumerLastCallsJsonV700 =
+ ConsumerLastCallsJsonV700(
+ tracked_since = lastCalls.trackedSince,
+ consumer_last_calls = lastCalls.lastCalls.toList
+ .sortBy { case (consumerId, at) => (-at.getTime, consumerId) }
+ .map { case (consumerId, at) => ConsumerLastCallJsonV700(consumerId, at) }
+ )
+
+ lazy val consumerLastCallsJsonV700Example = ConsumerLastCallsJsonV700(
+ tracked_since = Some(APIUtil.DateWithDayExampleObject),
+ consumer_last_calls = List(
+ ConsumerLastCallJsonV700("7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", APIUtil.DateWithDayExampleObject)
+ )
+ )
+
// ─── Top Users (v7.0.0) ───
// One distinct user and their call count. On-behalf-of aware: consent-borne calls are
// attributed to the granting human (resolved via the consent table), so for a given
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700DomainApis.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700DomainApis.scala
index 2bfa6c0c55..67007d4660 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700DomainApis.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700DomainApis.scala
@@ -36,8 +36,12 @@ import code.domainapi.DomainApiTrait
* JSONFactory700Operations.
*/
-/** Register or change a Domain API. */
-case class PostDomainApiJsonV700(base_path: String, version: String, title: String, description: Option[String])
+/**
+ * Register or change a Domain API. `is_publicly_discoverable` is false when left out on create, and keeps
+ * its current value when left out on update.
+ */
+case class PostDomainApiJsonV700(base_path: String, version: String, title: String, description: Option[String],
+ is_publicly_discoverable: Option[Boolean] = None)
case class DomainApiJsonV700(
domain_api_id: String,
@@ -46,6 +50,7 @@ case class DomainApiJsonV700(
version: String,
title: String,
description: String,
+ is_publicly_discoverable: Boolean,
/** Where the Domain API is published: this instance's host followed by the base path. */
url: String,
/** Its OpenAPI document. */
@@ -57,6 +62,19 @@ case class DomainApiJsonV700(
case class DomainApisJsonV700(domain_apis: List[DomainApiJsonV700])
+/** A publicly discoverable Domain API, as anyone may see it: what it is and where, not who made it. */
+case class PublicDomainApiJsonV700(
+ bank_id: String,
+ base_path: String,
+ version: String,
+ title: String,
+ description: String,
+ url: String,
+ openapi_url: String
+)
+
+case class PublicDomainApisJsonV700(domain_apis: List[PublicDomainApiJsonV700])
+
object JSONFactory700DomainApis {
def createDomainApiJson(domainApi: DomainApiTrait): DomainApiJsonV700 = DomainApiJsonV700(
@@ -66,6 +84,7 @@ object JSONFactory700DomainApis {
version = domainApi.version,
title = domainApi.title,
description = domainApi.description,
+ is_publicly_discoverable = domainApi.isPubliclyDiscoverable,
url = s"$HostName/${domainApi.basePath}",
openapi_url = s"$HostName/${domainApi.basePath}/openapi.yaml",
created_by_user_id = domainApi.createdByUserId,
@@ -73,11 +92,22 @@ object JSONFactory700DomainApis {
updated_at = domainApi.updatedAt
)
+ def createPublicDomainApiJson(domainApi: DomainApiTrait): PublicDomainApiJsonV700 = PublicDomainApiJsonV700(
+ bank_id = domainApi.bankId,
+ base_path = domainApi.basePath,
+ version = domainApi.version,
+ title = domainApi.title,
+ description = domainApi.description,
+ url = s"$HostName/${domainApi.basePath}",
+ openapi_url = s"$HostName/${domainApi.basePath}/openapi.yaml"
+ )
+
val postDomainApiJsonV700Example = PostDomainApiJsonV700(
base_path = "carbon-registry/v1",
version = "1.0.0",
title = "Open Carbon Registry API",
- description = Some("Activities and land parcels of the registry.")
+ description = Some("Activities and land parcels of the registry."),
+ is_publicly_discoverable = Some(true)
)
val domainApiJsonV700Example = DomainApiJsonV700(
@@ -87,6 +117,7 @@ object JSONFactory700DomainApis {
version = "1.0.0",
title = "Open Carbon Registry API",
description = "Activities and land parcels of the registry.",
+ is_publicly_discoverable = true,
url = "https://api.example.org/carbon-registry/v1",
openapi_url = "https://api.example.org/carbon-registry/v1/openapi.yaml",
created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1",
@@ -95,4 +126,14 @@ object JSONFactory700DomainApis {
)
val domainApisJsonV700Example = DomainApisJsonV700(List(domainApiJsonV700Example))
+
+ val publicDomainApisJsonV700Example = PublicDomainApisJsonV700(List(PublicDomainApiJsonV700(
+ bank_id = domainApiJsonV700Example.bank_id,
+ base_path = domainApiJsonV700Example.base_path,
+ version = domainApiJsonV700Example.version,
+ title = domainApiJsonV700Example.title,
+ description = domainApiJsonV700Example.description,
+ url = domainApiJsonV700Example.url,
+ openapi_url = domainApiJsonV700Example.openapi_url
+ )))
}
diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
index 99f90e0355..e63d3d9076 100644
--- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
@@ -3945,7 +3945,9 @@ object LocalMappedConnector extends Connector with MdcLoggable {
value: String,
callContext: Option[CallContext]): OBPReturnType[Box[UserAuthContextUpdate]] = {
val consumerId = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("")).getOrElse("")
- UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend.createUserAuthContextUpdates(userId,consumerId, key, value) map {
+ // This connector method has no bank, so the update it creates is bound to none and its answer is refused
+ // at every bank. validateUserAuthContextUpdateRequest creates updates bound to their bank.
+ UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend.createUserAuthContextUpdates(userId, consumerId, "", key, value) map {
(_, callContext)
}
}
@@ -5435,7 +5437,9 @@ object LocalMappedConnector extends Connector with MdcLoggable {
body.to_counterparty.get
}
counterpartyId = CounterpartyId(bodyToCounterparty.counterparty_id)
- (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(counterpartyId, callContext)
+ // The payee was checked against the paying account when the request was created; check it again
+ // here, so a stored request can never pay a counterparty of another account.
+ (toCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(fromAccount.bankId, fromAccount.accountId, counterpartyId.value, callContext)
(toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, callContext)
counterpartyBody = TransactionRequestBodyCounterpartyJSON(
to = CounterpartyIdJson(counterpartyId.value),
@@ -5739,8 +5743,11 @@ object LocalMappedConnector extends Connector with MdcLoggable {
}
//1st: check if the customer is existing
(customer, callContext) <- NewStyle.function.getCustomerByCustomerNumber(value, BankId(bankId), callContext)
- //2rd: if the customer is existing, we can create the userAuthContextUpdateChallenge
- (userAuthContextUpdate, callContext) <- NewStyle.function.createUserAuthContextUpdate(userId, key, value, callContext)
+ //2rd: if the customer is existing, we can create the userAuthContextUpdateChallenge, bound to this bank and only answerable here
+ consumerId = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("")).getOrElse("")
+ userAuthContextUpdate <- UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend
+ .createUserAuthContextUpdates(userId, consumerId, bankId, key, value)
+ .map(update => unboxFullOrFail(update, callContext, CreateUserAuthContextError, 400))
//3rd: send the challenge to the user.
_ <- Future{
scaMethod match {
diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
index e1d4b272fc..ee97aec326 100644
--- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
@@ -1250,7 +1250,9 @@ object LocalMappedConnectorInternal extends MdcLoggable {
json.extract[TransactionRequestBodyCardJsonV400]
}
toCounterpartyId = transactionRequestBodyCard.to.counterparty_id
- (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(CounterpartyId(toCounterpartyId), callContext)
+ // The payee must be a counterparty of the card's account, as for COUNTERPARTY. Otherwise whoever
+ // presents the card could pay any beneficiary counterparty of any other account.
+ (toCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(fromAccount.bankId, fromAccount.accountId, toCounterpartyId, callContext)
(toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, callContext)
// Check we can send money to it.
_ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc=callContext) {
diff --git a/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdate.scala b/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdate.scala
index 7df97fdef4..d329fd614d 100644
--- a/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdate.scala
+++ b/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdate.scala
@@ -47,6 +47,9 @@ class MappedUserAuthContextUpdate extends UserAuthContextUpdate with LongKeyedMa
override def defaultValue = SecureRandomUtil.csprng.nextInt(99999999).toString()
}
object mStatus extends MappedString(this, 20)
+ // The bank the update was requested at. Its answer is only accepted at the same bank, because a key such as
+ // CUSTOMER_NUMBER names a Customer only within one bank. Empty for updates requested before this was recorded.
+ object BankId extends MappedString(this, 255)
override def userId = mUserId.get
override def consumerId: String = mConsumerId.get
@@ -55,6 +58,7 @@ class MappedUserAuthContextUpdate extends UserAuthContextUpdate with LongKeyedMa
override def userAuthContextUpdateId = mUserAuthContextUpdateId.get
override def challenge: String = mChallenge.get
override def status: String = mStatus.get
+ def bankId: String = BankId.get
}
diff --git a/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdateProvider.scala b/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdateProvider.scala
index 75bb4d5338..5f28852e6d 100644
--- a/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdateProvider.scala
+++ b/obp-api/src/main/scala/code/context/MappedUserAuthContextUpdateProvider.scala
@@ -42,13 +42,14 @@ import scala.concurrent.Future
object MappedUserAuthContextUpdateProvider extends UserAuthContextUpdateProvider with MdcLoggable {
- override def createUserAuthContextUpdates(userId: String, consumerId:String, key: String, value: String): Future[Box[MappedUserAuthContextUpdate]] =
+ override def createUserAuthContextUpdates(userId: String, consumerId:String, bankId: String, key: String, value: String): Future[Box[MappedUserAuthContextUpdate]] =
Future {
tryo {
MappedUserAuthContextUpdate
.create
.mUserId(userId)
.mConsumerId(consumerId)
+ .BankId(bankId)
.mKey(key)
.mValue(value)
.mStatus(UserAuthContextUpdateStatus.INITIATED.toString)
@@ -59,6 +60,13 @@ object MappedUserAuthContextUpdateProvider extends UserAuthContextUpdateProvider
override def getUserAuthContextUpdates(userId: String): Future[Box[List[MappedUserAuthContextUpdate]]] = Future {
getUserAuthContextUpdatesBox(userId)
}
+ override def getUserAuthContextUpdateOfUserAtBank(authContextUpdateId: String, userId: String, bankId: String): Box[MappedUserAuthContextUpdate] =
+ MappedUserAuthContextUpdate.find(
+ By(MappedUserAuthContextUpdate.mUserAuthContextUpdateId, authContextUpdateId),
+ By(MappedUserAuthContextUpdate.mUserId, userId),
+ By(MappedUserAuthContextUpdate.BankId, bankId)
+ )
+
override def getUserAuthContextUpdatesBox(userId: String): Box[List[MappedUserAuthContextUpdate]] = {
tryo {
MappedUserAuthContextUpdate.findAll(By(MappedUserAuthContextUpdate.mUserId, userId))
diff --git a/obp-api/src/main/scala/code/context/UserAuthContextUpdateProvider.scala b/obp-api/src/main/scala/code/context/UserAuthContextUpdateProvider.scala
index be44c4137b..90cf93baa8 100644
--- a/obp-api/src/main/scala/code/context/UserAuthContextUpdateProvider.scala
+++ b/obp-api/src/main/scala/code/context/UserAuthContextUpdateProvider.scala
@@ -44,7 +44,9 @@ object UserAuthContextUpdateProvider extends SimpleInjector {
}
trait UserAuthContextUpdateProvider {
- def createUserAuthContextUpdates(userId: String, consumerId:String, key: String, value: String): Future[Box[UserAuthContextUpdate]]
+ def createUserAuthContextUpdates(userId: String, consumerId:String, bankId: String, key: String, value: String): Future[Box[UserAuthContextUpdate]]
+ /** This returns the update only if it was requested by the User userId at the bank bankId. */
+ def getUserAuthContextUpdateOfUserAtBank(authContextUpdateId: String, userId: String, bankId: String): Box[UserAuthContextUpdate]
def getUserAuthContextUpdates(userId: String): Future[Box[List[UserAuthContextUpdate]]]
def getUserAuthContextUpdatesBox(userId: String): Box[List[UserAuthContextUpdate]]
def deleteUserAuthContextUpdates(userId: String): Future[Box[Boolean]]
diff --git a/obp-api/src/main/scala/code/domainapi/DomainApi.scala b/obp-api/src/main/scala/code/domainapi/DomainApi.scala
index c53b1bed2f..b815b744d1 100644
--- a/obp-api/src/main/scala/code/domainapi/DomainApi.scala
+++ b/obp-api/src/main/scala/code/domainapi/DomainApi.scala
@@ -59,6 +59,8 @@ trait DomainApiTrait {
def version: String
def title: String
def description: String
+ /** Whether anyone, without logging in, may find it through GET /obp/v7.0.0/banks/BANK_ID/domain-apis. */
+ def isPubliclyDiscoverable: Boolean
def createdByUserId: String
def createdAt: Date
def updatedAt: Date
@@ -72,13 +74,15 @@ case class DomainApiRoute(domainApiId: String, bankId: String, basePath: String,
trait DomainApiProvider {
def create(bankId: String, basePath: String, version: String, title: String, description: String,
- createdByUserId: String): Box[DomainApiTrait]
+ isPubliclyDiscoverable: Boolean, createdByUserId: String): Box[DomainApiTrait]
def get(bankId: String, domainApiId: String): Box[DomainApiTrait]
def getAll(bankId: String): Box[List[DomainApiTrait]]
+ /** The Domain APIs of a space that are publicly discoverable. */
+ def getPubliclyDiscoverable(bankId: String): Box[List[DomainApiTrait]]
/** Every Domain API of every space, read from the table (not the request path's cached list). */
def getAllInEverySpace(): Box[List[DomainApiTrait]]
def update(bankId: String, domainApiId: String, basePath: String, version: String, title: String,
- description: String): Box[DomainApiTrait]
+ description: String, isPubliclyDiscoverable: Boolean): Box[DomainApiTrait]
def delete(bankId: String, domainApiId: String): Box[Boolean]
/** Every Domain API of every space, for the request path; see [[DomainApiDbProvider.routes]]. */
def routes(): List[DomainApiRoute]
@@ -113,7 +117,7 @@ object DomainApiDbProvider extends DomainApiProvider {
}
override def create(bankId: String, basePath: String, version: String, title: String, description: String,
- createdByUserId: String): Box[DomainApiTrait] = {
+ isPubliclyDiscoverable: Boolean, createdByUserId: String): Box[DomainApiTrait] = {
val created = tryo {
DomainApi.create
.DomainApiId(APIUtil.generateUUID())
@@ -122,6 +126,7 @@ object DomainApiDbProvider extends DomainApiProvider {
.Version(version)
.Title(title)
.Description(description)
+ .IsPubliclyDiscoverable(isPubliclyDiscoverable)
// A consent user's Domain API belongs to the person the Consent is for (UserReference).
.CreatedByUserId(code.users.Users.users.vend.attributedUserId(createdByUserId, code.users.UserReference.DomainApi_CreatedByUserId).openOr(createdByUserId))
.saveMe()
@@ -136,14 +141,18 @@ object DomainApiDbProvider extends DomainApiProvider {
override def getAll(bankId: String): Box[List[DomainApiTrait]] =
tryo(DomainApi.findAll(By(DomainApi.BankId, bankId), OrderBy(DomainApi.BasePath, Ascending)))
+ override def getPubliclyDiscoverable(bankId: String): Box[List[DomainApiTrait]] =
+ tryo(DomainApi.findAll(By(DomainApi.BankId, bankId), By(DomainApi.IsPubliclyDiscoverable, true), OrderBy(DomainApi.BasePath, Ascending)))
+
override def getAllInEverySpace(): Box[List[DomainApiTrait]] =
tryo(DomainApi.findAll(OrderBy(DomainApi.BasePath, Ascending)))
override def update(bankId: String, domainApiId: String, basePath: String, version: String, title: String,
- description: String): Box[DomainApiTrait] = {
+ description: String, isPubliclyDiscoverable: Boolean): Box[DomainApiTrait] = {
val updated = DomainApi.find(By(DomainApi.BankId, bankId), By(DomainApi.DomainApiId, domainApiId)).flatMap { row =>
tryo {
- row.BasePath(basePath).Version(version).Title(title).Description(description).UpdatedAt(new Date()).saveMe()
+ row.BasePath(basePath).Version(version).Title(title).Description(description)
+ .IsPubliclyDiscoverable(isPubliclyDiscoverable).UpdatedAt(new Date()).saveMe()
}
}
forget()
@@ -180,6 +189,11 @@ class DomainApi extends DomainApiTrait with LongKeyedMapper[DomainApi] with IdPK
object Description extends MappedString(this, 2000) {
override def dbColumnName = "description"
}
+ // False until its owner says otherwise: a Domain API is only listed publicly on purpose.
+ object IsPubliclyDiscoverable extends MappedBoolean(this) {
+ override def dbColumnName = "is_publicly_discoverable"
+ override def defaultValue = false
+ }
object CreatedByUserId extends MappedString(this, 255) {
override def dbColumnName = "created_by_user_id"
}
@@ -198,6 +212,7 @@ class DomainApi extends DomainApiTrait with LongKeyedMapper[DomainApi] with IdPK
override def version: String = Version.get
override def title: String = Title.get
override def description: String = Option(Description.get).getOrElse("")
+ override def isPubliclyDiscoverable: Boolean = IsPubliclyDiscoverable.get
override def createdByUserId: String = CreatedByUserId.get
override def createdAt: Date = CreatedAt.get
override def updatedAt: Date = UpdatedAt.get
diff --git a/obp-api/src/main/scala/code/metrics/ConsumerLastCall.scala b/obp-api/src/main/scala/code/metrics/ConsumerLastCall.scala
new file mode 100644
index 0000000000..d46cbfcd78
--- /dev/null
+++ b/obp-api/src/main/scala/code/metrics/ConsumerLastCall.scala
@@ -0,0 +1,100 @@
+/**
+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.metrics
+
+import code.api.cache.Redis
+import code.api.Constant
+import code.util.Helper.MdcLoggable
+import com.openbankproject.commons.ExecutionContext.Implicits.global
+
+import java.util.Date
+import java.util.concurrent.ConcurrentHashMap
+import scala.concurrent.Future
+import scala.util.control.NonFatal
+
+/**
+ * When each Consumer last called the API, kept in Redis only, never in the database.
+ *
+ * One hash, `{namespace}consumer_last_call`, maps consumer_id to epoch seconds, so one HGETALL
+ * answers for every Consumer. It is lost when Redis restarts; `{namespace}consumer_last_call_since`
+ * says when the hash started filling, so a reader can tell "no call since then" from "never".
+ *
+ * Each node writes a Consumer at most once per ThrottleSeconds, so a busy Consumer costs one Redis
+ * write a minute per node, not one per call. The write is async and its failure is only logged:
+ * the API never waits for, or fails because of, this.
+ */
+object ConsumerLastCall extends MdcLoggable {
+
+ val ThrottleSeconds = 60
+
+ // consumer_id -> epoch seconds this node last wrote for it
+ private val lastWritten = new ConcurrentHashMap[String, java.lang.Long]()
+
+ def hashKey: String = s"${Constant.getGlobalCacheNamespacePrefix}consumer_last_call"
+ def sinceKey: String = s"${hashKey}_since"
+
+ case class LastCalls(trackedSince: Option[Date], lastCalls: Map[String, Date])
+
+ /** Called once per API call. Returns the write it started, if the throttle let one through. */
+ def record(consumerId: String, at: Date): Option[Future[Unit]] =
+ if (consumerId == null || consumerId.isEmpty || at == null) None
+ else {
+ val seconds = at.getTime / 1000
+ if (!claim(consumerId, seconds)) None
+ else Some(Future {
+ try Redis.hashSetIfGreater(hashKey, sinceKey, consumerId, seconds)
+ catch {
+ case NonFatal(e) =>
+ // Let the next call retry rather than wait out the throttle.
+ lastWritten.remove(consumerId, seconds: java.lang.Long)
+ logger.debug(s"ConsumerLastCall says: could not record consumer_id $consumerId: ${e.getMessage}")
+ }
+ })
+ }
+
+ /** Atomically take this node's write slot for the Consumer, so concurrent calls write once. */
+ private def claim(consumerId: String, seconds: Long): Boolean = {
+ val previous = lastWritten.get(consumerId)
+ if (previous == null) lastWritten.putIfAbsent(consumerId, seconds) == null
+ else if (seconds - previous < ThrottleSeconds) false
+ else lastWritten.replace(consumerId, previous, seconds)
+ }
+
+ def getAll(): LastCalls = {
+ val raw = Redis.hashGetAll(hashKey)
+ val since = Redis.use(code.api.JedisMethod.GET, sinceKey)
+ def toDate(seconds: String): Option[Date] = scala.util.Try(new Date(seconds.toLong * 1000)).toOption
+ LastCalls(
+ trackedSince = since.flatMap(toDate),
+ lastCalls = raw.flatMap { case (consumerId, seconds) => toDate(seconds).map(consumerId -> _) }
+ )
+ }
+
+ /** Tests only: forget this node's throttle so the next call for a Consumer writes. */
+ private[code] def forgetThrottle(): Unit = lastWritten.clear()
+}
diff --git a/obp-api/src/test/scala/code/api/DirectLoginTest.scala b/obp-api/src/test/scala/code/api/DirectLoginTest.scala
index 928ce45bc6..ae7eded3f1 100644
--- a/obp-api/src/test/scala/code/api/DirectLoginTest.scala
+++ b/obp-api/src/test/scala/code/api/DirectLoginTest.scala
@@ -234,6 +234,30 @@ class DirectLoginTest extends ServerSetup with BeforeAndAfter {
assertResponse(response, ErrorMessages.InvalidConsumerKey)
}
+ scenario("DirectLogin is refused, on both paths, when allow_direct_login is false") {
+ Given("an instance where DirectLogin is turned off")
+ setPropsValues("allow_direct_login" -> "false")
+
+ When("valid credentials are posted, in the DirectLogin header only, to the unversioned and the v6.0.0 endpoint")
+ val versionedRequest = baseRequest / "obp" / "v6.0.0" / "my" / "logins" / "direct"
+ List(directLoginRequest, versionedRequest).foreach { request =>
+ val response = makePostRequestAdditionalHeader(request, "", List(accessControlOriginHeader, validHeader))
+ Then("each is refused with 403 DirectLoginIsDisabled and no token is issued")
+ withClue(s"${request.url} ${org.json4s.native.JsonMethods.compact(org.json4s.native.JsonMethods.render(response.body))}") {
+ response.code should equal(403)
+ assertResponse(response, ErrorMessages.DirectLoginIsDisabled)
+ (response.body \ "token") should equal(JNothing)
+ }
+ }
+ }
+
+ scenario("The v6.0.0 endpoint issues a token when DirectLogin is allowed") {
+ val response = makePostRequestAdditionalHeader(
+ baseRequest / "obp" / "v6.0.0" / "my" / "logins" / "direct", "", validHeaders)
+ response.code should equal(201)
+ (response.body \ "token").extract[String].length should be > 0
+ }
+
scenario("Missing DirectLogin header") {
//setupUserAndConsumer
diff --git a/obp-api/src/test/scala/code/api/OAuth2ConsumerResolutionTest.scala b/obp-api/src/test/scala/code/api/OAuth2ConsumerResolutionTest.scala
index 419b6b3730..8f4c77954f 100644
--- a/obp-api/src/test/scala/code/api/OAuth2ConsumerResolutionTest.scala
+++ b/obp-api/src/test/scala/code/api/OAuth2ConsumerResolutionTest.scala
@@ -34,7 +34,7 @@ import code.setup.ServerSetup
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
-import net.liftweb.common.Empty
+import net.liftweb.common.{Empty, Failure}
import net.liftweb.mapper.By
import java.net.URI
@@ -100,6 +100,38 @@ class OAuth2ConsumerResolutionTest extends ServerSetup {
}
}
+ // A client_credentials-style token: no azp, optionally a client_id claim, sub = the client.
+ private def clientCredentialsToken(clientId: Option[String], iss: String): String = {
+ val builder = new JWTClaimsSet.Builder().issuer(iss).subject(clientId.getOrElse(APIUtil.generateUUID())).audience(iss)
+ clientId.foreach(builder.claim("client_id", _))
+ val jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), builder.build())
+ jwt.sign(new MACSigner("0123456789abcdef0123456789abcdef"))
+ jwt.serialize()
+ }
+
+ feature("tokens without an azp claim") {
+
+ scenario("the client_id claim stands in for azp, so repeated calls reuse one consumer") {
+ val clientId = APIUtil.generateUUID()
+ When("the same client presents two tokens carrying client_id but no azp")
+ val first = resolve(clientCredentialsToken(Some(clientId), googleIssuer))
+ val second = resolve(clientCredentialsToken(Some(clientId), googleIssuer))
+ Then("both resolve to one consumer keyed by the client_id")
+ second.consumerId.get should equal(first.consumerId.get)
+ first.azp.get should equal(clientId)
+ Consumer.findAll(By(Consumer.azp, clientId)).size should equal(1)
+ }
+
+ scenario("a token with neither azp nor client_id does not auto-create a consumer") {
+ val before = Consumer.count()
+ When("a token identifies no client")
+ val result = oidcProvider.getOrCreateConsumer(clientCredentialsToken(None, googleIssuer), Empty, Some("OpenID Connect"))
+ Then("resolution fails and no consumer is created")
+ result shouldBe a[Failure]
+ Consumer.count() should equal(before)
+ }
+ }
+
feature("auto-created consumer metadata") {
scenario("the consumer is named from the token's name claim, falling back to the description") {
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala
index 10c372edc1..91c416fd8d 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala
@@ -36,6 +36,9 @@ import code.consumer.Consumers
import code.context.UserAuthContextUpdateProvider
import code.entitlement.Entitlement
import code.scope.Scope
+import code.customer.CustomerX
+import com.openbankproject.commons.model.{BankId, CustomerFaceImage}
+import java.util.Date
import code.usercustomerlinks.UserCustomerLink
import code.api.util.ErrorMessages
import com.github.dwickern.macros.NameOf.nameOf
@@ -116,6 +119,84 @@ class UserAuthContextUpdateTest extends V310ServerSetup {
And("A wrong answer must not link the User to the Customer")
UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false)
}
+ scenario("A request needs the App Scope or the User Role CanCreateUserAuthContextUpdate at the bank", ApiEndpoint1, VersionOfApi) {
+ val bankId = testBankId1.value
+ val otherBankId = testBankId2.value
+ Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser2.userId, CanCreateCustomer.toString)
+ val customerResponse = makePostRequest((v3_1_0_Request / "banks" / bankId / "customers").POST <@(user2), write(postCustomerJson))
+ customerResponse.code should equal(201)
+ val customerNumber = customerResponse.body.extract[CustomerJsonV310].customer_number
+ val scaMethod = StrongCustomerAuthentication.EMAIL.toString
+ def requestUpdate() = makePostRequest(
+ (v3_1_0_Request / "banks" / bankId / "users" / "current" / "auth-context-updates" / scaMethod).POST <@(user2),
+ write(postUserAuthContextJson.copy(value = customerNumber)))
+ def numberOfUpdatesOfUser2 = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend
+ .getUserAuthContextUpdatesBox(resourceUser2.userId).toList.flatten.size
+
+ When("Neither the App nor the User has it")
+ val updatesBefore = numberOfUpdatesOfUser2
+ val refused = requestUpdate()
+ Then("It is refused with 403 and no update is created, so nothing is sent")
+ refused.code should equal(403)
+ (refused.body \ "message").extract[String] should include(ErrorMessages.ConsumerHasMissingRoles)
+ numberOfUpdatesOfUser2 should equal(updatesBefore)
+
+ When("Only the User has the Role, at another bank")
+ Entitlement.entitlement.vend.addEntitlement(otherBankId, resourceUser2.userId, ApiRole.canCreateUserAuthContextUpdate.toString)
+ Then("It is still refused")
+ requestUpdate().code should equal(403)
+
+ When("The User has the Role at the bank, and the App has no Scope")
+ Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser2.userId, ApiRole.canCreateUserAuthContextUpdate.toString)
+ Then("The update is created")
+ requestUpdate().code should equal(201)
+ }
+ scenario("An update can only be answered at the bank it was requested at", ApiEndpoint1, ApiEndpoint2, VersionOfApi) {
+ val requestBankId = testBankId1.value
+ val otherBankId = testBankId2.value
+ val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(user1.get._1.key).map(_.id.get.toString).getOrElse("")
+ Scope.scope.vend.addScope(requestBankId, consumerId, ApiRole.canCreateUserAuthContextUpdate.toString())
+ Entitlement.entitlement.vend.addEntitlement(requestBankId, resourceUser1.userId, CanCreateCustomer.toString)
+
+ Given("A Customer with the same customer number at each of two banks")
+ val customerAtRequestBank = makePostRequest(
+ (v3_1_0_Request / "banks" / requestBankId / "customers").POST <@(user1), write(postCustomerJson))
+ customerAtRequestBank.code should equal(201)
+ val customerNumber = customerAtRequestBank.body.extract[CustomerJsonV310].customer_number
+ val customerIdAtRequestBank = customerAtRequestBank.body.extract[CustomerJsonV310].customer_id
+ val customerIdAtOtherBank = CustomerX.customerProvider.vend.addCustomer(
+ BankId(otherBankId), customerNumber, "Customer At Other Bank", "+49 123 456", "other-bank-customer@example.com",
+ CustomerFaceImage(new Date(), "http://example.com/face.png"),
+ new Date(), "single", 0, Nil, "Bachelor", "employed", true, new Date(),
+ None, None, "", "", ""
+ ).openOrThrowException("expected the customer at the other bank to be created").customerId
+
+ val scaMethod = StrongCustomerAuthentication.SMS.toString
+ val createResponse = makePostRequest(
+ (v3_1_0_Request / "banks" / requestBankId / "users" / "current" / "auth-context-updates" / scaMethod).POST <@(user1),
+ write(postUserAuthContextJson.copy(value = customerNumber)))
+ createResponse.code should equal(201)
+ val authContextUpdateId = createResponse.body.extract[UserAuthContextUpdateJson].user_auth_context_update_id
+ val challenge = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend.getUserAuthContextUpdatesBox(resourceUser1.userId)
+ .toList.flatten.filter(_.userAuthContextUpdateId == authContextUpdateId).map(_.challenge).head
+ def answerAt(bankId: String) = makePostRequest(
+ (v3_1_0_Request / "banks" / bankId / "users" / "current" / "auth-context-updates" / authContextUpdateId / "challenge").POST <@(user1),
+ write(PostUserAuthContextUpdateJsonV310(answer = challenge)))
+
+ When("The right answer is sent with the other bank in the path")
+ val answerAtOtherBank = answerAt(otherBankId)
+ Then("It is refused as not found, and the User is not linked to the other bank's Customer")
+ answerAtOtherBank.code should equal(400)
+ (answerAtOtherBank.body \ "message").extract[String] should include(ErrorMessages.UserAuthContextUpdateNotFound)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, customerIdAtOtherBank).isDefined should equal(false)
+
+ And("The same answer at the bank of the request is accepted and links only that bank's Customer")
+ val answerAtRequestBank = answerAt(requestBankId)
+ answerAtRequestBank.code should equal(200)
+ answerAtRequestBank.body.extract[UserAuthContextUpdateJson].status should equal(UserAuthContextUpdateStatus.ACCEPTED.toString)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, customerIdAtRequestBank).isDefined should equal(true)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, customerIdAtOtherBank).isDefined should equal(false)
+ }
scenario("Another User cannot answer, or use up, the challenge of a User's update", ApiEndpoint1, ApiEndpoint2, VersionOfApi) {
val bankId = randomBankId
val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(user1.get._1.key).map(_.id.get.toString).getOrElse("")
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala
index 69f088c2e1..8779c14833 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala
@@ -1558,6 +1558,49 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers {
feature("we can create transaction requests -- CARD") {
+ if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
+ ignore("A CARD payee must be a counterparty of the card's account", ApiEndpoint10) {}
+ } else {
+ scenario("A CARD payee must be a counterparty of the card's account", ApiEndpoint10) {
+ setPropsValues("transactionRequests_supported_types" -> "SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,ACCOUNT_OTP,SIMPLE,CARD")
+ setPropsValues("CARD_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY")
+ val helper = defaultSetup(CARD.toString)
+
+ Given("a beneficiary counterparty that belongs to another account, not the card's")
+ val counterpartyOfAnotherAccount = createCounterparty(
+ helper.bankId.value, helper.accountId2.value, helper.accountId1.value, true, UUID.randomUUID.toString)
+ helper.transactionRequestBodyCard = helper.transactionRequestBodyCard.copy(
+ to = CounterpartyIdJson(counterpartyOfAnotherAccount.counterpartyId))
+
+ When("the card is charged with that payee")
+ val response = helper.makeCreateTransReqRequestCard
+
+ Then("it is refused as not found and no money moves")
+ response.code should equal(400)
+ (response.body \ "message").extract[String] should include(CounterpartyNotFoundByCounterpartyId)
+ helper.checkBankAccountBalance(false)
+ }
+ }
+
+ if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
+ ignore("Charging a card needs access to the card's account", ApiEndpoint10) {}
+ } else {
+ scenario("Charging a card needs access to the card's account", ApiEndpoint10) {
+ setPropsValues("transactionRequests_supported_types" -> "SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,ACCOUNT_OTP,SIMPLE,CARD")
+ setPropsValues("CARD_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY")
+ val helper = defaultSetup(CARD.toString)
+
+ When("a User with no view on the card's account presents the right card details")
+ val requestByUser2 = (v4_0_0_Request / "transaction-request-types" / "CARD" / "transaction-requests").POST <@ (user2)
+ val response = makePostRequest(requestByUser2, write(helper.transactionRequestBodyCard))
+
+ Then("it is refused and no money moves")
+ response.code should equal(400)
+ (response.body \ "message").extract[String] should include(InsufficientAuthorisationToCreateTransactionRequest)
+ helper.checkBankAccountBalance(false)
+ }
+ }
+
if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
ignore("No challenge, No FX ", ApiEndpoint10) {}
} else {
diff --git a/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala
index a9e4aa3da5..ce69932798 100644
--- a/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala
+++ b/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala
@@ -165,6 +165,7 @@ class UserAuthContextTest extends V500ServerSetup {
val customerNumner = infoPost.customer_number
When(s"We try to create the UserAuthContextRequestUpdate v5.0.0 $ApiEndpoint3")
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateUserAuthContextUpdate.toString)
val requestUserAuthContext500 = (v5_0_0_Request / "banks"/testBankId1.value / "users" / "current" / "auth-context-updates" / "SMS" ).POST <@(user1)
val responseUserAuthContext500 = makePostRequest(requestUserAuthContext500, write(postUserAuthContextJsonV310.copy(value = customerNumner)))
Then("We should get a 201")
diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
index 0ae1a9fd5e..2d240978cb 100644
--- a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
+++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
@@ -251,6 +251,8 @@ class ConsumerTest extends V510ServerSetup {
val consumers = responseApiEndpoint2.body.extract[ConsumersJsonV510].consumers
consumers.find(_.consumer_id ==consumerId).head.redirect_url shouldBe(consumerRedirectUrlJSON.redirect_url)
consumers.find(_.consumer_id ==consumerId).head.logo_url.head shouldBe(consumerLogoUrlJson.logo_url)
+ And("no consumer key is handed out by the list")
+ consumers.map(_.consumer_key).distinct shouldBe List(code.api.util.APIUtil.RedactedConsumerKey)
// Endpoint GetConsumer
val requestApiEndpoint5 = (v5_1_0_Request / "management" / "consumers" / consumerId).GET <@ user1
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/ConsumerLastCallsTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerLastCallsTest.scala
new file mode 100644
index 0000000000..8d4096fc89
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerLastCallsTest.scala
@@ -0,0 +1,134 @@
+/**
+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.cache.Redis
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.CanReadMetrics
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles}
+import code.api.v6_0_0.V600ServerSetup
+import code.api.v7_0_0.JSONFactory700.ConsumerLastCallsJsonV700
+import code.entitlement.Entitlement
+import code.metrics.ConsumerLastCall
+import code.setup.RedisTestTarget
+import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.util.ApiVersion
+import org.scalatest.Tag
+import org.scalatest.concurrent.Eventually._
+import org.scalatest.time.{Seconds, Span}
+
+import java.util.{Date, UUID}
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+/**
+ * GET /obp/v7.0.0/management/metrics/consumer-last-calls and the Redis-only recording behind it
+ * (code.metrics.ConsumerLastCall): throttled per node, never moved backwards, read in one HGETALL.
+ */
+class ConsumerLastCallsTest extends V600ServerSetup {
+
+ def v7_0_0_Request = baseRequest / "obp" / "v7.0.0"
+
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object ApiEndpoint1 extends Tag("getConsumerLastCalls")
+
+ private def endpoint = v7_0_0_Request / "management" / "metrics" / "consumer-last-calls"
+
+ private def lastCallsAsUser1(): ConsumerLastCallsJsonV700 = {
+ val response = makeGetRequest(endpoint.GET <@ (user1))
+ response.code should equal(200)
+ response.body.extract[ConsumerLastCallsJsonV700]
+ }
+
+ private def write(consumerId: String, at: Date): Unit =
+ ConsumerLastCall.record(consumerId, at).foreach(Await.result(_, 10.seconds))
+
+ feature(s"test $ApiEndpoint1 version $VersionOfApi - access") {
+ scenario("Without user credentials", ApiEndpoint1, VersionOfApi) {
+ val response = makeGetRequest(endpoint.GET)
+ response.code should equal(401)
+ response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired)
+ }
+
+ scenario("Without CanReadMetrics", ApiEndpoint1, VersionOfApi) {
+ val response = makeGetRequest(endpoint.GET <@ (user1))
+ response.code should equal(403)
+ response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanReadMetrics)
+ }
+ }
+
+ feature("ConsumerLastCall throttle") {
+ scenario("A node writes a Consumer at most once per ThrottleSeconds", ApiEndpoint1, VersionOfApi) {
+ ConsumerLastCall.forgetThrottle()
+ val consumerId = "__last_call_throttle_" + UUID.randomUUID.toString.take(8)
+ val t0 = new Date()
+ ConsumerLastCall.record(consumerId, t0) shouldBe defined
+ ConsumerLastCall.record(consumerId, new Date(t0.getTime + 10 * 1000)) shouldBe empty
+ ConsumerLastCall.record(consumerId, new Date(t0.getTime + (ConsumerLastCall.ThrottleSeconds + 1) * 1000L)) shouldBe defined
+ And("a call without a Consumer records nothing")
+ ConsumerLastCall.record(null, t0) shouldBe empty
+ ConsumerLastCall.record("", t0) shouldBe empty
+ }
+ }
+
+ feature(s"test $ApiEndpoint1 version $VersionOfApi - reading what was recorded") {
+ scenario("The newest time wins, an older write never moves it back", ApiEndpoint1, VersionOfApi) {
+ RedisTestTarget.requireReachable(Redis.isRedisReady, "consumer last call recording")
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanReadMetrics.toString)
+ val consumerId = "__last_call_order_" + UUID.randomUUID.toString.take(8)
+ val newer = new Date((System.currentTimeMillis() / 1000) * 1000)
+ val older = new Date(newer.getTime - 3600 * 1000)
+
+ ConsumerLastCall.forgetThrottle()
+ write(consumerId, newer)
+ ConsumerLastCall.forgetThrottle()
+ write(consumerId, older)
+
+ val body = lastCallsAsUser1()
+ body.tracked_since shouldBe defined
+ body.consumer_last_calls.find(_.consumer_id == consumerId).map(_.last_call_at.getTime) shouldBe Some(newer.getTime)
+ And("the list is most recent first")
+ val times = body.consumer_last_calls.map(_.last_call_at.getTime)
+ times shouldBe times.sorted(Ordering[Long].reverse)
+ }
+
+ scenario("An API call records its Consumer", ApiEndpoint1, VersionOfApi) {
+ RedisTestTarget.requireReachable(Redis.isRedisReady, "consumer last call recording")
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanReadMetrics.toString)
+ ConsumerLastCall.forgetThrottle()
+ val before = (System.currentTimeMillis() / 1000) * 1000
+
+ makeGetRequest((v5_1_0_Request / "banks").GET <@ (user2)).code should equal(200)
+
+ eventually(timeout(Span(10, Seconds))) {
+ val recorded = lastCallsAsUser1().consumer_last_calls.find(_.consumer_id == testConsumer2.consumerId.get)
+ recorded.map(_.last_call_at.getTime >= before) shouldBe Some(true)
+ }
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/ConsumerV700Test.scala b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerV700Test.scala
new file mode 100644
index 0000000000..5db6169451
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/ConsumerV700Test.scala
@@ -0,0 +1,129 @@
+/**
+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.ResourceDocs1_4_0.SwaggerDefinitionsJSON
+import code.api.util.APIUtil
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole._
+import code.api.v5_1_0.ConsumerJsonV510
+import code.api.v6_0_0.{ConsumerJsonV600, V600ServerSetup}
+import code.api.v7_0_0.JSONFactory700.{ConsumerDetailsJsonV700, ConsumerJsonV700}
+import code.entitlement.Entitlement
+import com.openbankproject.commons.util.ApiVersion
+import org.json4s.JsonAST.JObject
+import org.json4s.native.Serialization.write
+import org.scalatest.Tag
+
+/** v7.0.0 Get Consumer and the four Consumer field updates answer without consumer_key; v6.0.0 Get Consumer redacts it. */
+class ConsumerV700Test extends V600ServerSetup {
+ def v7_0_0_Request = baseRequest / "obp" / "v7.0.0"
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object GetConsumer extends Tag("getConsumer")
+ object UpdateConsumerName extends Tag("updateConsumerName")
+ object UpdateConsumerRedirectURL extends Tag("updateConsumerRedirectURL")
+ object UpdateConsumerLogoURL extends Tag("updateConsumerLogoURL")
+ object UpdateConsumerCertificate extends Tag("updateConsumerCertificate")
+
+ private def fieldNames(body: org.json4s.JValue): Set[String] = body match {
+ case JObject(fields) => fields.map(_._1).toSet
+ case _ => Set.empty
+ }
+
+ private def createConsumerAsUser1(): (String, String) = {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1),
+ write(SwaggerDefinitionsJSON.createConsumerRequestJsonV510))
+ response.code should equal(201)
+ val created = response.body.extract[ConsumerJsonV510]
+ (created.consumer_id, created.consumer_key)
+ }
+
+ feature(s"Consumer responses without the key, version $VersionOfApi") {
+ scenario("A user without the Role cannot get a Consumer", GetConsumer, VersionOfApi) {
+ val response = makeGetRequest((v7_0_0_Request / "management" / "consumers" / testConsumer.consumerId.get).GET <@ (user1))
+ response.code should equal(403)
+ response.body.toString should include(canGetConsumers.toString)
+ }
+
+ scenario("Get Consumer has no consumer_key in v7.0.0 and a redacted one in v6.0.0", GetConsumer, VersionOfApi) {
+ val (consumerId, consumerKey) = createConsumerAsUser1()
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetConsumers.toString)
+
+ val v7 = makeGetRequest((v7_0_0_Request / "management" / "consumers" / consumerId).GET <@ (user1))
+ v7.code should equal(200)
+ v7.body.extract[ConsumerDetailsJsonV700].consumer_id should equal(consumerId)
+ fieldNames(v7.body) should not contain("consumer_key")
+ v7.body.toString should not include(consumerKey)
+
+ val v6 = makeGetRequest((v6_0_0_Request / "management" / "consumers" / consumerId).GET <@ (user1))
+ v6.code should equal(200)
+ v6.body.extract[ConsumerJsonV600].consumer_key should equal(APIUtil.RedactedConsumerKey)
+ v6.body.toString should not include(consumerKey)
+ }
+
+ scenario("The four updates change the field and answer without consumer_key",
+ UpdateConsumerName, UpdateConsumerRedirectURL, UpdateConsumerLogoURL, UpdateConsumerCertificate, VersionOfApi) {
+ val (consumerId, consumerKey) = createConsumerAsUser1()
+ List(canUpdateConsumerName, canUpdateConsumerRedirectUrl, canUpdateConsumerLogoUrl, canUpdateConsumerCertificate)
+ .foreach(role => Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, role.toString))
+ def consumerPath(field: String) = v7_0_0_Request / "management" / "consumers" / consumerId / "consumer" / field
+
+ val name = makePutRequest(consumerPath("name").PUT <@ (user1), write(SwaggerDefinitionsJSON.consumerNameJson))
+ name.code should equal(200)
+ name.body.extract[ConsumerJsonV700].app_name should equal(SwaggerDefinitionsJSON.consumerNameJson.app_name)
+
+ val redirect = makePutRequest(consumerPath("redirect_url").PUT <@ (user1), write(SwaggerDefinitionsJSON.consumerRedirectUrlJSON))
+ redirect.code should equal(200)
+ redirect.body.extract[ConsumerJsonV700].redirect_url should equal(SwaggerDefinitionsJSON.consumerRedirectUrlJSON.redirect_url)
+
+ val logo = makePutRequest(consumerPath("logo_url").PUT <@ (user1), write(SwaggerDefinitionsJSON.consumerLogoUrlJson))
+ logo.code should equal(200)
+ logo.body.extract[ConsumerJsonV700].logo_url should equal(Some(SwaggerDefinitionsJSON.consumerLogoUrlJson.logo_url))
+
+ val certificate = makePutRequest(consumerPath("certificate").PUT <@ (user1), write(SwaggerDefinitionsJSON.consumerCertificateJson))
+ certificate.code should equal(200)
+ certificate.body.extract[ConsumerJsonV700].certificate_pem should equal(SwaggerDefinitionsJSON.consumerCertificateJson.certificate)
+
+ List(name, redirect, logo, certificate).foreach { response =>
+ fieldNames(response.body) should not contain("consumer_key")
+ response.body.toString should not include(consumerKey)
+ }
+ }
+
+ scenario("The v5.1.0 updates keep the field but redact the key", UpdateConsumerName, VersionOfApi) {
+ val (consumerId, consumerKey) = createConsumerAsUser1()
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerName.toString)
+ val response = makePutRequest((v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "name").PUT <@ (user1),
+ write(SwaggerDefinitionsJSON.consumerNameJson))
+ response.code should equal(200)
+ response.body.extract[ConsumerJsonV510].consumer_key should equal(APIUtil.RedactedConsumerKey)
+ response.body.toString should not include(consumerKey)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala
index 213e6fbace..77a5713cdb 100644
--- a/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala
+++ b/obp-api/src/test/scala/code/api/v7_0_0/DomainApisTest.scala
@@ -64,6 +64,7 @@ class DomainApisTest extends V600ServerSetup {
object ApiEndpoint3 extends Tag("getDomainApi")
object ApiEndpoint4 extends Tag("updateDomainApi")
object ApiEndpoint5 extends Tag("deleteDomainApi")
+ object ApiEndpoint6 extends Tag("getPubliclyDiscoverableDomainApis")
private val SYS = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID
private val owner = "domain-api-owner"
@@ -73,8 +74,11 @@ class DomainApisTest extends V600ServerSetup {
private def v7 = baseRequest / "obp" / "v7.0.0"
private def domainApis(bankId: String) = v7 / "management" / "banks" / bankId / "domain-apis"
- private def registration(basePath: String, version: String, title: String = "Test Domain API") =
- compact(render(("base_path" -> basePath) ~ ("version" -> version) ~ ("title" -> title) ~ ("description" -> "For the test.")))
+ private def publicDomainApis(bankId: String) = v7 / "banks" / bankId / "domain-apis"
+ private def registration(basePath: String, version: String, title: String = "Test Domain API",
+ publiclyDiscoverable: Option[Boolean] = None) =
+ compact(render(("base_path" -> basePath) ~ ("version" -> version) ~ ("title" -> title) ~ ("description" -> "For the test.") ~
+ ("is_publicly_discoverable" -> publiclyDiscoverable)))
private def message(response: code.setup.APIResponse) = response.body.extract[ErrorMessage].message
private def grantAt(bankId: String, role: code.api.util.ApiRole) =
Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, role.toString)
@@ -166,6 +170,50 @@ class DomainApisTest extends V600ServerSetup {
message(gone) should include(DomainApiNotFound)
}
+ scenario("a Domain API is listed publicly only once it is made publicly discoverable",
+ ApiEndpoint1, ApiEndpoint4, ApiEndpoint6, VersionOfApi) {
+ grantAllAt(SYS)
+ val basePath = s"discoverable-$suffix/v1"
+ def publiclyListed: List[String] = {
+ val listed = makeGetRequest(publicDomainApis(SYS).GET)
+ withClue(listed.body) { listed.code should equal(200) }
+ valuesOf(listed.body \ "domain_apis", "base_path")
+ }
+
+ When("one is created without saying")
+ val created = makePostRequest(domainApis(SYS).POST <@ (user1), registration(basePath, "1.0.0"))
+ withClue(created.body) { created.code should equal(201) }
+ val domainApiId = (created.body \ "domain_api_id").extract[String]
+ Then("it is not publicly discoverable, and an anonymous caller does not find it")
+ (created.body \ "is_publicly_discoverable").extract[Boolean] should equal(false)
+ publiclyListed should not contain basePath
+
+ When("it is made publicly discoverable")
+ val made = makePutRequest((domainApis(SYS) / domainApiId).PUT <@ (user1), registration(basePath, "1.0.0", publiclyDiscoverable = Some(true)))
+ withClue(made.body) { made.code should equal(200) }
+ (made.body \ "is_publicly_discoverable").extract[Boolean] should equal(true)
+ Then("an anonymous caller finds it, with where it is published but not who made it")
+ publiclyListed should contain(basePath)
+ val listed = (makeGetRequest(publicDomainApis(SYS).GET).body \ "domain_apis").children
+ .find(api => (api \ "base_path").extract[String] == basePath).get
+ (listed \ "url").extract[String] should endWith(s"/$basePath")
+ (listed \ "openapi_url").extract[String] should endWith(s"/$basePath/openapi.yaml")
+ (listed \ "created_by_user_id") should equal(JNothing)
+
+ When("it is updated without saying")
+ val kept = makePutRequest((domainApis(SYS) / domainApiId).PUT <@ (user1), registration(basePath, "1.1.0"))
+ Then("it stays publicly discoverable")
+ (kept.body \ "is_publicly_discoverable").extract[Boolean] should equal(true)
+ publiclyListed should contain(basePath)
+
+ When("it is made not publicly discoverable")
+ makePutRequest((domainApis(SYS) / domainApiId).PUT <@ (user1), registration(basePath, "1.1.0", publiclyDiscoverable = Some(false))).code should equal(200)
+ Then("an anonymous caller no longer finds it")
+ publiclyListed should not contain basePath
+
+ makeDeleteRequest((domainApis(SYS) / domainApiId).DELETE <@ (user1)).code should equal(204)
+ }
+
scenario("base path, version and title are checked", ApiEndpoint1, VersionOfApi) {
grantAllAt(SYS)
def attempt(basePath: String, version: String, title: String = "Test Domain API") =
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala
new file mode 100644
index 0000000000..281862b9fa
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/GetConsumersTest.scala
@@ -0,0 +1,81 @@
+/**
+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.util.APIUtil.OAuth._
+import code.api.util.ApiRole.canGetConsumers
+import code.api.util.ErrorMessages.{ApplicationNotIdentified, UserHasMissingRoles}
+import code.api.v6_0_0.V600ServerSetup
+import code.api.v7_0_0.JSONFactory700.ConsumersJsonV700
+import code.entitlement.Entitlement
+import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.util.ApiVersion
+import org.json4s.JsonAST.{JArray, JObject}
+import org.scalatest.Tag
+
+/** GET /obp/v7.0.0/management/consumers: every Consumer, referenced by consumer_id, never with its key. */
+class GetConsumersTest extends V600ServerSetup {
+ def v7_0_0_Request = baseRequest / "obp" / "v7.0.0"
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object ApiEndpoint1 extends Tag("getConsumers")
+
+ feature(s"test $ApiEndpoint1 version $VersionOfApi") {
+ scenario("Without any credentials the application cannot be identified", ApiEndpoint1, VersionOfApi) {
+ val response = makeGetRequest((v7_0_0_Request / "management" / "consumers").GET)
+ Then("We should get a 401")
+ response.code should equal(401)
+ response.body.extract[ErrorMessage].message should include(ApplicationNotIdentified)
+ }
+
+ scenario("A user without the Role is refused", ApiEndpoint1, VersionOfApi) {
+ val response = makeGetRequest((v7_0_0_Request / "management" / "consumers").GET <@ (user1))
+ Then("We should get a 403")
+ response.code should equal(403)
+ response.body.extract[ErrorMessage].message should include(UserHasMissingRoles)
+ response.body.extract[ErrorMessage].message should include(canGetConsumers.toString)
+ }
+
+ scenario("With the Role the list has every Consumer and no consumer_key", ApiEndpoint1, VersionOfApi) {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetConsumers.toString)
+ val response = makeGetRequest((v7_0_0_Request / "management" / "consumers").GET <@ (user1))
+ Then("We should get a 200")
+ response.code should equal(200)
+ val consumers = response.body.extract[ConsumersJsonV700].consumers
+ consumers.map(_.consumer_id) should contain(testConsumer.consumerId.get)
+ And("no listed Consumer carries a consumer_key field")
+ val fieldNames = (response.body \ "consumers") match {
+ case JArray(items) => items.collect { case JObject(fields) => fields.map(_._1) }.flatten.toSet
+ case _ => Set.empty[String]
+ }
+ fieldNames should contain("consumer_id")
+ fieldNames should not contain("consumer_key")
+ And("the key value appears nowhere in the body")
+ response.body.toString should not include(testConsumer.key.get)
+ }
+ }
+}
diff --git a/release_notes.md b/release_notes.md
index bab02be71b..d2517dc253 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -3,6 +3,13 @@
### Most recent changes at top of file
```
Date Commit Action
+13/10/2026 TBD ADDED: Domain APIs have is_publicly_discoverable (new column
+ domain_api.is_publicly_discoverable, false by default). The new public
+ GET /obp/v7.0.0/banks/BANK_ID/domain-apis lists, without authentication, the
+ space's Domain APIs that have it set, with base path, version, title,
+ description, url and openapi_url. POST and PUT .../management/banks/BANK_ID/
+ domain-apis take it as an optional field: false when left out on create,
+ unchanged when left out on update. The management responses include it.
07/10/2026 TBD CHANGED: GET /management/aggregate-metrics (v3.0.0, v5.1.0 and v6.0.0)
covers at most 31 days of metrics per call, set by the new prop
aggregate_metrics_max_days (default 31). A range between from_date and
diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json
index e371131ee3..a5ee46917d 100644
--- a/scripts/resource_doc_baseline/parity_allowlist.json
+++ b/scripts/resource_doc_baseline/parity_allowlist.json
@@ -1607,9 +1607,9 @@
"version": "v4_0_0",
"endpoint": "createTransactionRequestCard",
"field": "errorResponseBodies",
- "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9.",
+ "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9. Also adds CounterpartyNotFoundByCounterpartyId, returned when the payee is not a counterparty of the card's account.",
"lift_digest": "4eac30b6b708ad9f30e688ef1327a01ff7f4280f982f77063851c9483e3179c2",
- "http4s_digest": "36b31730de78f93aa41f09da7fe44eff78fe76dac497497e7562b84454a2e3b0"
+ "http4s_digest": "8723ffa249614fed5a40227e79508a1dcbec5e5009357455d6a3204fe0db61fd"
},
{
"version": "v4_0_0",
@@ -1983,9 +1983,9 @@
"version": "v5_1_0",
"endpoint": "updateConsumerRedirectURL",
"field": "description",
- "reason": "Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.",
+ "reason": "Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry. States that consumer_key is returned as the redacted placeholder (APIUtil.RedactedConsumerKey) because the key is a credential, and that the v7.0.0 version leaves the field out.",
"lift_digest": "d2319e98d44314c5142a17c76318478bd3f2e88a41c560f30fd99ebde75a6b9b",
- "http4s_digest": "6ae0abfeccdb9959fc3efe679b4a279e6aefb89f584a3d8d0f025620f03329f9"
+ "http4s_digest": "d6d8aec47ff86dde95311cac66a3af9541941a575e32cc437a1ee51624d30b88"
},
{
"version": "v5_1_0",
@@ -2130,6 +2130,134 @@
"reason": "Adds AccountIdAlreadyExists: an existing ACCOUNT_ID is now refused, where it used to return the existing account and make the caller one of its holders.",
"lift_digest": "30b24bdbcdb523f00b5e4655fbdb91d8648ab84ee92f1f2292008816b8e31012",
"http4s_digest": "c48cc1204d4ea66a5f131c599ace7f8d502c91ce16a36bf0e507e06b915b8055"
+ },
+ {
+ "version": "v3_1_0",
+ "endpoint": "answerUserAuthContextUpdateChallenge",
+ "field": "description",
+ "reason": "States that only the User who requested the update can answer it, only at the bank it was requested at, and that only a right answer adds the User Auth Context and the Customer link.",
+ "lift_digest": "c2bb04751b64677723b0a5bfedfa0f92999b6277d60ff9172272ca4f454d39d1",
+ "http4s_digest": "45e6b2ef5bb600b2781f7f9e7392a0c952844ff67e6d893ab2aad2c028228d6f"
+ },
+ {
+ "version": "v3_1_0",
+ "endpoint": "answerUserAuthContextUpdateChallenge",
+ "field": "errorResponseBodies",
+ "reason": "Adds UserAuthContextUpdateNotFound, returned for an update that does not exist or belongs to another User or another bank.",
+ "lift_digest": "837babe0c6517335a32569673d5cdf0bdfb1cc40c06f8035e8e2f6099d3a6ab8",
+ "http4s_digest": "f1806abaa2f1ffebc6bcc48875179391170d2545566cc10d54b38f82172aa9e0"
+ },
+ {
+ "version": "v5_0_0",
+ "endpoint": "answerUserAuthContextUpdateChallenge",
+ "field": "description",
+ "reason": "States that only the User who requested the update can answer it, only at the bank it was requested at, and that only a right answer adds the User Auth Context and the Customer link.",
+ "lift_digest": "b84d8a8ba5729b2af8dd67bf06884afcef6b7f71ad5a0ea2a1bc125d59b4dcd1",
+ "http4s_digest": "83f377f201005db11bd081607fdf62ae680615e8cd55b3355473e31f9e9d14e8"
+ },
+ {
+ "version": "v5_0_0",
+ "endpoint": "answerUserAuthContextUpdateChallenge",
+ "field": "errorResponseBodies",
+ "reason": "Adds UserAuthContextUpdateNotFound, returned for an update that does not exist or belongs to another User or another bank.",
+ "lift_digest": "84013a06c4127d2ad932f7813a03432776c5a05651afca08dea52dcac780b8fb",
+ "http4s_digest": "3f2fa1dc012d5dcc93e4542e85d0ef5a5a03de2c3fa750aca33af628ee6c3073"
+ },
+ {
+ "version": "v3_1_0",
+ "endpoint": "createUserAuthContextUpdateRequest",
+ "field": "description",
+ "reason": "Says the challenge goes to the Customer's own contact details (not the User), and that the request needs the App Scope or the User Role CanCreateUserAuthContextUpdate at BANK_ID, which is now always checked.",
+ "lift_digest": "2bcfb3fcfe68cfeada6614a6a2bf57b289e488b3a4f029a733145e5c55e2c2f2",
+ "http4s_digest": "150d6804f8ea2eaba8edb3c8a11072ab626d87b38a200aba0a11d80d28cf6bd9"
+ },
+ {
+ "version": "v3_1_0",
+ "endpoint": "createUserAuthContextUpdateRequest",
+ "field": "errorResponseBodies",
+ "reason": "Adds ConsumerHasMissingRoles, returned when neither the App has the Scope nor the User the Role CanCreateUserAuthContextUpdate at BANK_ID.",
+ "lift_digest": "6a0652dbac6383372df2418f0456676eb1ab4ea8d7e3d5e10efa2e2bb81ff510",
+ "http4s_digest": "1769e014d63887938573f77828aaa7669b69c23f8c4de3bb286767aad5185754"
+ },
+ {
+ "version": "v5_0_0",
+ "endpoint": "createUserAuthContextUpdateRequest",
+ "field": "description",
+ "reason": "Says the challenge goes to the Customer's own contact details (not the User), and that the request needs the App Scope or the User Role CanCreateUserAuthContextUpdate at BANK_ID, which is now always checked.",
+ "lift_digest": "2bcfb3fcfe68cfeada6614a6a2bf57b289e488b3a4f029a733145e5c55e2c2f2",
+ "http4s_digest": "150d6804f8ea2eaba8edb3c8a11072ab626d87b38a200aba0a11d80d28cf6bd9"
+ },
+ {
+ "version": "v5_0_0",
+ "endpoint": "createUserAuthContextUpdateRequest",
+ "field": "errorResponseBodies",
+ "reason": "Adds ConsumerHasMissingRoles, returned when neither the App has the Scope nor the User the Role CanCreateUserAuthContextUpdate at BANK_ID.",
+ "lift_digest": "cdb22d6afff7a90bf4a665f9d7d1eed2d90324a073db56537360b1b61618cce3",
+ "http4s_digest": "ebe1e09a8ffca0760b403f3e695374ae943920e6c06ad876abf65b7aa0d82353"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "directLoginEndpoint",
+ "field": "description",
+ "reason": "Shows whether DirectLogin is enabled on this instance (allow_direct_login), and that when it is disabled every request is refused without checking credentials.",
+ "lift_digest": "cf1c92e99d25e17c7dbe3e026b3a7220df05647420fd298bdf4c71a8ef4265cb",
+ "http4s_digest": "fd9706da918ddc0d030f1d2b69264d4a9b6d3601702a4b00b9b118adf3fe3f3b"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "directLoginEndpoint",
+ "field": "errorResponseBodies",
+ "reason": "Adds DirectLoginIsDisabled: the endpoint now honours allow_direct_login=false, as the unversioned route does.",
+ "lift_digest": "dd71e2633d445b2639d923d757b5bcb72428648a15516b6f52ded226389118fd",
+ "http4s_digest": "be58d5096fa4da0374d728c775884b48c701e0b918021bf1bd4e50c8116da92a"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "getConsumers",
+ "field": "description",
+ "reason": "States that consumer_key is returned as the redacted placeholder (APIUtil.RedactedConsumerKey) because the key is a credential, and that the v7.0.0 version leaves the field out.",
+ "lift_digest": "3b89fa570a230d88a3f7986fef724e5ab07e148edf5b8052c86d1ea0b6f7ddd6",
+ "http4s_digest": "cc45fd404de4e435f603c1192dc63b2f777b7a0efd39c8ad8cd608ef0e604729"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "updateConsumerCertificate",
+ "field": "description",
+ "reason": "States that consumer_key is returned as the redacted placeholder (APIUtil.RedactedConsumerKey) because the key is a credential, and that the v7.0.0 version leaves the field out.",
+ "lift_digest": "7053f9b9b8a84366fb168cfabac9a9a2eddc764b1cddc5553d277b5b1ee6a3b4",
+ "http4s_digest": "5107ec99851efcb86f14ff4181f34cc49136bad40dfa275bc2354ef965c4c37a"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "updateConsumerLogoURL",
+ "field": "description",
+ "reason": "States that consumer_key is returned as the redacted placeholder (APIUtil.RedactedConsumerKey) because the key is a credential, and that the v7.0.0 version leaves the field out.",
+ "lift_digest": "24970bab545a3bde99ab3755beb387be7428f0e759697ead1cf38482e932ee7b",
+ "http4s_digest": "2c520696ebcc58f97d55da0f0b7bf583bf809b92a07a98975f879056bfd5a5f1"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "updateConsumerName",
+ "field": "description",
+ "reason": "States that consumer_key is returned as the redacted placeholder (APIUtil.RedactedConsumerKey) because the key is a credential, and that the v7.0.0 version leaves the field out.",
+ "lift_digest": "692ebc3c9d61794ffa3032f1cd759b586823239cccdf3a78c58ea9253bd3a159",
+ "http4s_digest": "a8bdac82543f60b89328544bdd0027259566919925b24729a7c84f30a795ca56"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "getConsumer",
+ "field": "description",
+ "reason": "States that consumer_key is returned as the redacted placeholder (APIUtil.RedactedConsumerKey) because the key is a credential, and that the v7.0.0 version leaves the field out.",
+ "lift_digest": "36e9d04801335bdeaf8c6db9f748a43a9093d49933f7e9f173a2350acae7d51d",
+ "http4s_digest": "3b4b63fce351b6ea6e44e6be2fa6c7fe59ca5051b5a69cd755b4a3478798ac1c"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "createTransactionRequestCard",
+ "field": "description",
+ "reason": "States that the paying account is the card's account, that the User needs the owner view with permission to add transaction requests (or CanCreateAnyTransactionRequest), and that the payee must be a counterparty of that account.",
+ "lift_digest": "b670d8181d5d2d563db3a015bb357107c827ad273e2097c4215032c07e39502b",
+ "http4s_digest": "b60ff8942421ae14a3245bcc8625bac7409abef79994972c629d56dafc582840"
}
]
}