diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template
index 2433cd22b5..a9792a942a 100644
--- a/obp-api/src/main/resources/props/sample.props.template
+++ b/obp-api/src/main/resources/props/sample.props.template
@@ -257,6 +257,21 @@ write_connector_metrics=false
## Enable writing connector traces (full outbound/inbound message payloads per call) to RDBMS table `connector_trace`. Verbose — keep off in prod unless debugging.
write_connector_trace=false
+## Client addresses behind a proxy. By default OBP-API takes the client address from the TCP peer,
+## which behind a reverse proxy, load balancer or server-side application is the proxy's address: every
+## per-IP limit, IP penalty and the busiest-callers view would then see one address. When a proxy sits
+## in front, let it set a header with the client's address (it MUST overwrite any value the client sent,
+## e.g. NGINX `proxy_set_header X-Real-IP $remote_addr;`) and trust that header here.
+## X-Forwarded-For is also accepted (its leftmost address is used) when the proxy sanitises the chain.
+## trust.proxy.peers limits whose header is believed: the addresses or CIDR ranges of the proxies (and
+## of server-side applications that pass on their users' addresses). A header from any other peer is
+## ignored. Unset, the header is believed from anyone, so a caller that reaches OBP-API directly can
+## name any address it likes. Deployment Checks (GET /obp/v7.0.0/management/system/diagnostics/deployment,
+## or Observe > Deployment Checks in API Manager) shows whether these are right for the traffic seen.
+# trust.proxy.enabled=false
+# trust.proxy.header=X-Real-IP
+# trust.proxy.peers=10.0.0.0/8, 2001:db8::/32
+
## Telemetry: aggregated numbers about this instance (request rates and durations per endpoint,
## Connector calls, caches, the database pool, memory, garbage collection, threads) for Prometheus.
## Not API Metrics: see docs/telemetry_conventions.md. Telemetry is always recorded; these props
@@ -879,7 +894,7 @@ super_admin_user_ids=USER_ID1,USER_ID2,
##################################################################################
# List of Users that should automatically have roles needed to call endpoints used by OBP-OIDC or OBP Keycloak Provider.
-# The following users will automatically have: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient
+# The following users will automatically have: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers, CanCreateConsumer
# oidc_operator_user_ids=USER_ID1,USER_ID2,
####################################################################################
@@ -1965,7 +1980,7 @@ regulated_entities = []
# Bootstrap OIDC Operator User
# Given the following credentials, OBP will create a user if they do not already exist.
-# This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers
+# This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers, CanCreateConsumer
# If you want to use this feature, please set up all three values properly at the same time.
# oidc_operator_username=...
# oidc_operator_initial_password=...
diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index 714cca0964..012c447af9 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -865,7 +865,7 @@ class Boot extends MdcLoggable {
/**
* Bootstrap OIDC Operator User
* Given the following credentials, OBP will create a user *if it does not exist already*.
- * This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers
+ * This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers, CanCreateConsumer
*/
private def createBootstrapOidcOperatorUser() = {
@@ -905,7 +905,8 @@ class Boot extends MdcLoggable {
CanVerifyUserCredentials,
CanVerifyOidcClient,
CanGetOidcClient,
- CanGetConsumers
+ CanGetConsumers,
+ CanCreateConsumer
)
userBox match {
@@ -1133,10 +1134,13 @@ object ToSchemify extends MdcLoggable {
CounterpartyAttributeMapper,
BankAccountBalance,
Group,
+ code.group.GroupMembership,
Organisation,
RoutingScheme,
BankSupportedRoutingScheme,
code.glossaryitem.DynamicGlossaryItem,
+ code.platformapp.PlatformApp,
+ code.platformapp.PlatformAppRequiredScope,
PayeeLookup,
UtilityPaymentCallback,
BulkPayment,
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 751523505f..cabf606bc5 100644
--- a/obp-api/src/main/scala/code/api/util/APIUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala
@@ -2256,7 +2256,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
// Virtual roles granted by super_admin_user_ids prop
val superAdminVirtualRoles: List[String] = List("CanCreateEntitlementAtOneBank", "CanCreateEntitlementAtAnyBank", "CanGetAnyUser")
// Virtual roles granted by oidc_operator_user_ids prop
- val oidcOperatorVirtualRoles: List[String] = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient")
+ val oidcOperatorVirtualRoles: List[String] = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient", "CanGetConsumers", "CanCreateConsumer")
def hasScope(bankId: String, consumerId: String, role: ApiRole): Boolean = {
!Scope.scope.vend.getScope(bankId, consumerId, role.toString).isEmpty
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 c590525fe5..65d2c113b4 100644
--- a/obp-api/src/main/scala/code/api/util/ApiRole.scala
+++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala
@@ -580,6 +580,16 @@ object ApiRole extends MdcLoggable{
case class CanDeleteIpPenalty(requiresBankId: Boolean = false) extends ApiRole
lazy val canDeleteIpPenalty = CanDeleteIpPenalty()
+ // Platform Apps: the Consumers an installation runs as part of its own deployment (Portal, API Manager...).
+ case class CanCreatePlatformApp(requiresBankId: Boolean = false) extends ApiRole
+ lazy val canCreatePlatformApp = CanCreatePlatformApp()
+
+ case class CanGetPlatformApps(requiresBankId: Boolean = false) extends ApiRole
+ lazy val canGetPlatformApps = CanGetPlatformApps()
+
+ case class CanDeletePlatformApp(requiresBankId: Boolean = false) extends ApiRole
+ lazy val canDeletePlatformApp = CanDeletePlatformApp()
+
// Shows which Consumers and client IP addresses are sending the most traffic to the instance
// (TrafficSources). About the instance, so held at the empty bank id. It names Consumers and IP
// addresses, which is why it is a Role of its own and not part of CanGetTelemetry.
diff --git a/obp-api/src/main/scala/code/api/util/DeploymentChecks.scala b/obp-api/src/main/scala/code/api/util/DeploymentChecks.scala
new file mode 100644
index 0000000000..7c22605fe3
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/util/DeploymentChecks.scala
@@ -0,0 +1,270 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.util
+
+import code.telemetry.{Telemetry, TrafficSources}
+
+import scala.util.Try
+
+/**
+ * This object checks how this OBP-API instance and the applications in front of it are set up, as
+ * far as can be told from inside: its props, and the traffic of the last 15 minutes.
+ *
+ * It exists because the protections against scans and floods (per-IP limits, IP penalties, the
+ * busiest-callers view) are only as good as the client address OBP-API sees, and a wrong proxy or
+ * application setup makes them useless or harmful without any error anywhere: every caller looks
+ * like the proxy, or a caller can name any address it likes. Those mistakes show in the traffic, so
+ * they can be found without asking anyone how the deployment was built.
+ *
+ * Each check says whether it was worked out from traffic (`observed`), from props (`configured`),
+ * or cannot be seen from inside OBP-API at all (`manual`), and never guesses: with too little
+ * traffic to judge, an observed check says so.
+ */
+object DeploymentChecks {
+
+ final case class Check(
+ id: String,
+ title: String,
+ area: String,
+ basis: String, // observed | configured | manual
+ status: String, // OK | INFO | WARNING | ERROR | MANUAL
+ message: String,
+ evidence: List[(String, String)],
+ props: List[String]
+ )
+
+ val WindowMinutes = 15
+ /** Fewer requests than this in the window is too little traffic to judge an observed check. */
+ val MinRequestsToJudge = 20L
+ /** An application seen for at least this many users from a single address is not passing on addresses. */
+ val UsersFromOneAddress = 5
+
+ private def percent(part: Long, whole: Long): String = if (whole == 0) "0%" else f"${part * 100.0 / whole}%.0f%%"
+
+ private def isPrivate(address: String): Boolean =
+ Try(com.google.common.net.InetAddresses.forString(address)).toOption.exists { a =>
+ a.isLoopbackAddress || a.isSiteLocalAddress || a.isLinkLocalAddress ||
+ (a.getAddress.length == 16 && (a.getAddress()(0) & 0xfe) == 0xfc) // IPv6 unique local, fc00::/7
+ }
+
+ def run(): List[Check] = clientAddressChecks ++ applicationChecks ++ rateLimitChecks ++ observabilityChecks
+
+ // ===== Client addresses =====
+
+ private def clientAddressChecks: List[Check] = {
+ val trustEnabled = APIUtil.getPropsAsBoolValue("trust.proxy.enabled", false)
+ val header = APIUtil.getPropsValue("trust.proxy.header", "X-Real-IP")
+ val peersConfigured = RemoteIpUtil.trustedPeers.nonEmpty
+ val minutes = TrafficSources.forwarding(WindowMinutes)
+ val requests = minutes.map(_.requests).sum
+ val withHeader = minutes.map(_.withForwardingHeader).sum
+ val untrusted = minutes.map(_.headerFromUntrustedPeer).sum
+ val peers = minutes.flatMap(_.peers).distinct
+ val peersSendingHeader = minutes.flatMap(_.peersSendingHeader).distinct
+ val tooLittle = requests < MinRequestsToJudge
+ val forwardingProps = List("trust.proxy.enabled", "trust.proxy.header", "trust.proxy.peers")
+ val trafficEvidence = List(
+ "requests (last 15 minutes)" -> requests.toString,
+ "with a forwarding header" -> s"$withHeader (${percent(withHeader, requests)})",
+ "distinct TCP peers" -> peers.size.toString,
+ "TCP peers sending the header" -> peersSendingHeader.take(10).mkString(", "))
+
+ val forwarding = {
+ val (status, message) =
+ if (tooLittle) ("INFO", s"Too little traffic in the last $WindowMinutes minutes to judge ($requests requests).")
+ else if (!trustEnabled && withHeader * 2 >= requests)
+ ("ERROR", s"${percent(withHeader, requests)} of requests carry a forwarding header (X-Real-IP or X-Forwarded-For), so a proxy is " +
+ "passing on client addresses, but trust.proxy.enabled is false and OBP-API ignores them. Every caller appears to come from the " +
+ "proxy: per-IP limits, IP penalties and the busiest-callers view all see one address.")
+ else if (!trustEnabled && withHeader > 0)
+ ("WARNING", s"${percent(withHeader, requests)} of requests carry a forwarding header, which OBP-API ignores (trust.proxy.enabled is false). " +
+ "Some traffic may come through a proxy whose client addresses are lost.")
+ else if (!trustEnabled)
+ ("OK", "No forwarding header seen: OBP-API uses the TCP peer as the client address, which is right when nothing sits in front of it.")
+ else if (withHeader * 2 < requests)
+ ("WARNING", s"trust.proxy.enabled is true but only ${percent(withHeader, requests)} of requests carry $header. The proxy may not set it, " +
+ "or traffic reaches OBP-API without going through the proxy.")
+ else ("OK", s"Client addresses are taken from $header, present on ${percent(withHeader, requests)} of requests.")
+ Check("check_client_address_forwarding", "Client addresses are passed on and used", "Client addresses", "observed",
+ status, message, trafficEvidence, forwardingProps)
+ }
+
+ val trustedPeers = {
+ val (status, message) =
+ if (!trustEnabled) ("OK", "OBP-API does not take client addresses from headers, so it cannot be told a false one.")
+ else if (!peersConfigured)
+ ("WARNING", s"$header is believed from whoever sends it (trust.proxy.peers is not set). A caller that reaches OBP-API directly, " +
+ "bypassing the proxy, can name any address: to slip past per-IP limits, or to have someone else penalised. " +
+ "List the proxy's addresses in trust.proxy.peers.")
+ else if (untrusted > 0)
+ ("WARNING", s"$untrusted requests in the last $WindowMinutes minutes carried $header from a peer not in trust.proxy.peers; the header was " +
+ "ignored. Something reaches OBP-API without going through the proxy.")
+ else ("OK", s"$header is believed only from the peers in trust.proxy.peers.")
+ Check("check_trusted_proxy_peers", "Only the proxy can name a client address", "Client addresses", "configured",
+ status, message,
+ List("trust.proxy.peers" -> APIUtil.getPropsValue("trust.proxy.peers", "(not set)"),
+ "headers ignored from untrusted peers (last 15 minutes)" -> untrusted.toString),
+ forwardingProps)
+ }
+
+ val concentration = {
+ val busiest = TrafficSources.addresses(WindowMinutes).headOption
+ val (status, message) = busiest match {
+ case _ if tooLittle => ("INFO", s"Too little traffic in the last $WindowMinutes minutes to judge ($requests requests).")
+ case Some(top) if top.requests * 10 >= requests * 8 && isPrivate(top.key) =>
+ ("WARNING", s"${percent(top.requests, requests)} of requests come from ${top.key}, a private address. That is usually a proxy or " +
+ "an application whose callers' addresses are not passed on, not a real client.")
+ case Some(top) if top.requests * 10 >= requests * 8 =>
+ ("INFO", s"${percent(top.requests, requests)} of requests come from one address, ${top.key}. Check it is a real client and not a proxy.")
+ case _ => ("OK", "No single address carries most of the traffic.")
+ }
+ Check("check_address_concentration", "Traffic is spread across real client addresses", "Client addresses", "observed",
+ status, message,
+ busiest.map(top => List("busiest address" -> top.key, "its share" -> percent(top.requests, requests),
+ "private address" -> isPrivate(top.key).toString)).getOrElse(Nil),
+ forwardingProps)
+ }
+
+ List(forwarding, trustedPeers, concentration)
+ }
+
+ // ===== Applications =====
+
+ private def applicationChecks: List[Check] = {
+ val consumers = TrafficSources.consumers(WindowMinutes)
+ val notForwarding = consumers.flatMap { c =>
+ val users = c.details.flatMap(_.users).distinct
+ val addresses = c.details.flatMap(_.addresses).distinct
+ if (users.size >= UsersFromOneAddress && addresses.size == 1) {
+ val name = c.details.map(_.name).find(_.nonEmpty).getOrElse(c.key)
+ Some(s"$name (${c.key}): ${users.size}+ users, all from ${addresses.head}")
+ } else None
+ }
+ val (status, message) =
+ if (consumers.isEmpty) ("INFO", s"No authenticated traffic in the last $WindowMinutes minutes to judge.")
+ else if (notForwarding.nonEmpty)
+ ("WARNING", s"${notForwarding.size} application(s) call OBP-API for several users from a single address, so their users' addresses " +
+ "are not passed on. Per-IP limits and penalties then treat all their users as one caller. Such an application should send its " +
+ "user's address in the forwarding header, and be listed in trust.proxy.peers.")
+ else ("OK", "No application calls for many users from a single address.")
+ List(Check("check_applications_pass_on_addresses", "Applications pass on their users' addresses", "Applications", "observed",
+ status, message, notForwarding.zipWithIndex.map { case (line, i) => s"application ${i + 1}" -> line },
+ List("trust.proxy.enabled", "trust.proxy.header", "trust.proxy.peers")))
+ }
+
+ // ===== Rate limits =====
+
+ private def warnedSinceStart(scope: String): Long =
+ Option(Telemetry.registry.find("obp.api.self_service_rate_limit.checks").tags("scope", scope, "outcome", "warned").counter())
+ .map(_.count().toLong).getOrElse(0L)
+
+ private def rateLimitChecks: List[Check] = {
+ val scopes = SelfServiceRateLimiter.scopeDefaults.keys.toList.sorted
+ val shadowScopes = scopes.filter(s => SelfServiceRateLimiter.modeFor(s) == SelfServiceRateLimiter.ModeShadow)
+ val selfService = Check("check_self_service_mode", "Per-IP limits are enforced", "Rate limits", "configured",
+ if (!SelfServiceRateLimiter.enabled) "WARNING" else if (shadowScopes.nonEmpty) "INFO" else "OK",
+ if (!SelfServiceRateLimiter.enabled) "The self-service (per-IP) limiter is switched off."
+ else if (shadowScopes.nonEmpty) s"${shadowScopes.size} of ${scopes.size} scopes are in shadow mode: over their limit, a request is warned, " +
+ "not refused. The evidence shows how many requests each would have refused since start-up; check those before enforcing."
+ else "Every scope is enforced.",
+ scopes.map(s => s"$s (${SelfServiceRateLimiter.modeFor(s)})" -> s"${warnedSinceStart(s)} would have been refused since start-up"),
+ List("self_service.rate_limit.enabled", "self_service.rate_limit.mode", "self_service.rate_limit..mode"))
+
+ val windows = List("rate_limiting_per_second", "rate_limiting_per_minute", "rate_limiting_per_hour", "rate_limiting_per_day")
+ val consumerDefaults = windows.map(w => w -> APIUtil.getPropsAsLongValue(w, -1L))
+ val consumerCheck = Check("check_consumer_default_limits", "Consumers without their own limits are limited", "Rate limits", "configured",
+ if (consumerDefaults.forall(_._2 < 0)) "WARNING" else "OK",
+ if (consumerDefaults.forall(_._2 < 0))
+ "A Consumer with no rate limit rows of its own has no limit at all (every rate_limiting_per_* prop is -1)."
+ else "Consumers without their own rate limit rows get the defaults shown.",
+ consumerDefaults.map { case (w, v) => w -> v.toString }, windows)
+
+ val anonymous = APIUtil.getPropsAsIntValue("user_consumer_limit_anonymous_access", 1000)
+ val anonymousCheck = Check("check_anonymous_limit", "Anonymous calls are limited per address", "Rate limits", "configured",
+ if (anonymous < 0) "WARNING" else "OK",
+ if (anonymous < 0) "Anonymous calls have no hourly limit (-1)."
+ else s"Anonymous calls are limited to $anonymous an hour per client address (applies to endpoints behind the middleware).",
+ List("user_consumer_limit_anonymous_access" -> anonymous.toString), List("user_consumer_limit_anonymous_access"))
+
+ val edge = Check("check_edge_rate_limits", "The proxy and API Explorer limit requests too", "Rate limits", "manual",
+ "MANUAL", "Limits set in a proxy, load balancer or API Explorer cannot be seen from inside OBP-API. Confirm they exist: during the " +
+ "NMB scan of 2026-09-23, API Explorer passed about 33,000 of 52,000 requests straight through.",
+ Nil, Nil)
+
+ List(selfService, consumerCheck, anonymousCheck, edge)
+ }
+
+ // ===== Observability and the NMB causes =====
+
+ private def observabilityChecks: List[Check] = {
+ val root = org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)
+ val level = root match {
+ case logback: ch.qos.logback.classic.Logger => Option(logback.getEffectiveLevel).map(_.toString).getOrElse("unknown")
+ case _ => "unknown"
+ }
+ val logCheck = Check("check_root_log_level", "The root log level is not DEBUG", "Observability", "configured",
+ if (level == "DEBUG" || level == "TRACE") "ERROR" else "OK",
+ if (level == "DEBUG" || level == "TRACE")
+ s"The root log level is $level. Logging every request at $level costs CPU and memory under load; it was one of the causes of the NMB " +
+ "outage of 2026-09-23. Use INFO in production (logback.xml, or LOG_LEVEL)."
+ else s"The root log level is $level.",
+ List("effective root log level" -> level), List("logback.xml root level"))
+
+ val port = Telemetry.portSettings
+ val scrape = Telemetry.lastScrapeMillis
+ val ageSeconds = scrape.map(t => (System.currentTimeMillis() - t) / 1000)
+ val telemetryCheck = Check("check_telemetry_collection", "Prometheus collects Telemetry", "Observability", "observed",
+ if (!port.enabled) "INFO" else if (ageSeconds.forall(_ > 300)) "WARNING" else "OK",
+ if (!port.enabled) "The Telemetry port is not open, so Prometheus cannot collect Telemetry from this instance."
+ else ageSeconds match {
+ case None => s"The Telemetry port is open on ${port.port}, but nothing has collected from it since this instance started."
+ case Some(age) if age > 300 => s"Telemetry was last collected $age seconds ago; Prometheus may have stopped."
+ case Some(age) => s"Telemetry was last collected $age seconds ago."
+ },
+ List("port open" -> port.enabled.toString, "port" -> port.port.toString,
+ "last collected" -> scrape.map(t => java.time.Instant.ofEpochMilli(t).toString).getOrElse("never since start-up")),
+ List("telemetry.port.enabled", "telemetry.port", "telemetry.host"))
+
+ val redisReachable = Try(code.api.cache.Redis.use(code.api.JedisMethod.GET, s"${code.api.Constant.getGlobalCacheNamespacePrefix}deployment_check", None, None)).isSuccess
+ val redisCheck = Check("check_redis", "Redis is reachable", "Observability", "observed",
+ if (redisReachable) "OK" else "ERROR",
+ if (redisReachable) "Redis answered." else "Redis did not answer. Caches, rate-limit counters, IP penalties and cache namespaces all fail open without it.",
+ Nil, Nil)
+
+ import scala.jdk.CollectionConverters._
+ val lost = Telemetry.registry.find("obp.api.batch_writer.rows").tags("result", "lost").counters().asScala.map(_.count().toLong).sum
+ val metricsCheck = Check("check_api_metrics", "API Metrics are recorded without loss", "Observability", "observed",
+ if (!code.metrics.MetricsProps.writeMetrics) "INFO" else if (lost > 0) "WARNING" else "OK",
+ if (!code.metrics.MetricsProps.writeMetrics) "API Metrics are not recorded on this instance (write_metrics is false)."
+ else if (lost > 0) s"$lost API Metrics or Connector Metrics records have been lost to failed database writes since start-up."
+ else "API Metrics are recorded, with no records lost since start-up.",
+ List("write_metrics" -> code.metrics.MetricsProps.writeMetrics.toString, "records lost since start-up" -> lost.toString),
+ List("write_metrics", "write_connector_metrics"))
+
+ List(logCheck, telemetryCheck, redisCheck, metricsCheck)
+ }
+}
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 acc523e232..fac7b40ff4 100644
--- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
+++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
@@ -871,6 +871,10 @@ object ErrorMessages {
val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. "
val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. "
val ConsentConsumerIsRequired = "OBP-35044: A Consent must name a Consumer. Send consumer_id in the request body naming the Consumer the Consent is for, or make the call as that Consumer. "
+ val PlatformAppAlreadyExists = "OBP-35045: This Consumer is already a Platform App."
+ val PlatformAppNotFound = "OBP-35046: This Consumer is not a Platform App. An administrator marks a Consumer as a Platform App first (POST /management/platform-apps)."
+ val InvalidPlatformApp = "OBP-35047: Invalid Platform App. label must be between 1 and 100 characters."
+ val InvalidPlatformAppDeclaration = "OBP-35048: Invalid Platform App declaration. Send at most 100 required_scopes, each with a known role_name, a bank_id that suits the Role (empty for a system Role), and needed_for between 1 and 1000 characters; version is at most 100 characters."
//Authorisations
val AuthorisationNotFound = "OBP-36001: Authorisation not found. Please specify valid values for PAYMENT_ID and AUTHORISATION_ID. "
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 ff49ca3911..9eeecdb82a 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -105,7 +105,31 @@ object Glossary extends MdcLoggable {
|""".stripMargin
// We use the requested title rather than the found item's, because anchors are case sensitive.
- private def renderGlossaryItemLink(title: String): String = s"""[here](/glossary#${title})"""
+ // A space would end the markdown link's destination, so it is encoded; browsers decode it when
+ // they look for the anchor.
+ private def renderGlossaryItemLink(title: String): String =
+ s"""[here](${apiExplorerUrl}/glossary#${title.replace(" ", "%20")})"""
+
+ /** Where the API Explorer runs: the Glossary, Resource Docs and Message Docs are shown there. */
+ def apiExplorerUrl: String = APIUtil.getPropsValue("webui_api_explorer_url", "http://localhost:5174").stripSuffix("/")
+
+ /** Where the OBP Portal runs. */
+ def portalUrl: String = APIUtil.getPropsValue("webui_obp_portal_url", "http://localhost:5174").stripSuffix("/")
+
+ // A markdown link destination or an href that is a path on the API Explorer, without its host.
+ private val SiteRelativeExplorerLink = """(\]\(|href=")(/(?:glossary|index|resource-docs|message-docs|operationid)\b|/\?)""".r
+
+ /**
+ * Makes the links in Glossary text to the API Explorer's own pages (other Glossary Items, Resource
+ * Docs, Message Docs) fully qualified, so they work wherever the text is shown: the API Explorer,
+ * the Portal, the API Manager, Opey or any other client. Every Glossary Item passes through here,
+ * so an item written with a site-relative link is still served correctly. External links are
+ * already absolute and are left alone.
+ */
+ def qualifyExplorerLinks(text: String): String =
+ if (text == null) text
+ else SiteRelativeExplorerLink.replaceAllIn(text, m =>
+ java.util.regex.Matcher.quoteReplacement(m.group(1) + apiExplorerUrl + m.group(2)))
/**
* Expands any Glossary placeholders in the given markdown. Text with no placeholder is returned
@@ -238,8 +262,11 @@ object Glossary extends MdcLoggable {
// Constructs a GlossaryItem from just two parameters.
def apply(title: String, description: => String): GlossaryItem = {
+ // Links to the API Explorer's own pages are served fully qualified (see qualifyExplorerLinks).
+ def qualifiedDescription: String = qualifyExplorerLinks(description)
+
// Convert markdown to HTML
- val htmlDescription = PegdownOptions.convertPegdownToHtmlTweaked(description)
+ val htmlDescription = PegdownOptions.convertPegdownToHtmlTweaked(qualifiedDescription)
// Try and generate a plain text string (requires valid HTML)
val textDescription: String = try {
@@ -251,7 +278,7 @@ object Glossary extends MdcLoggable {
new GlossaryItem(
title,
- () => description,
+ () => qualifiedDescription,
htmlDescription,
textDescription
)
@@ -413,11 +440,9 @@ object Glossary extends MdcLoggable {
s"""$title"""
}
- // Consumer registration URL helper
- def getConsumerRegistrationUrl(): String = {
- val apiExplorerUrl = APIUtil.getPropsValue("webui_api_explorer_url", "http://localhost:5174")
- s"$apiExplorerUrl/consumers/register"
- }
+ // Consumer registration URL helper: registration is a Portal page, unless the installation sends it elsewhere.
+ def getConsumerRegistrationUrl(): String =
+ APIUtil.getPropsValue("webui_external_consumer_registration_url").openOr(s"$portalUrl/consumers/register")
glossaryItems += GlossaryItem(
title = "Cheat Sheet",
@@ -6865,6 +6890,81 @@ object Glossary extends MdcLoggable {
""")
+ glossaryItems += GlossaryItem(
+ title = "Platform Apps",
+ description =
+ s"""
+ |# Platform Apps
+ |
+ |A **Platform App** is an application an installation runs as part of its own OBP deployment, such as the Portal, the API Manager, Opey or one of the bank's own services. Like any application it calls OBP as a Consumer, and some of its calls are made with its own application token (OAuth2 client credentials) rather than for a logged-in User: reading published pages for anonymous visitors, or creating the Dynamic Entities it depends on at startup. Those calls need Roles granted to its Consumer as Scopes.
+ |
+ |## How it works
+ |
+ |1. An administrator marks the app's Consumer as a Platform App (Role CanCreatePlatformApp), giving it the name administrators know it by.
+ |2. The app declares, as itself, the Scopes it needs and what each is needed for. It does this whenever it starts or checks itself, so the list follows the version that is running. An app whose Consumer has not been marked cannot declare anything, so an arbitrary Consumer cannot ask to be granted Scopes this way.
+ |3. OBP compares each declaration with the Scopes the Consumer holds. An administrator with CanGetPlatformApps sees, for every Platform App, which Scopes are held and which are missing, and grants the missing ones (CanCreateScopeAtAnyBank, or CanCreateScopeAtOneBank at the Scope's bank id).
+ |
+ |A Scope declared as optional is one the app can manage without, for example because another Platform App does the same work. It is shown, but does not count as missing.
+ |
+ |Each app can also check its own Consumer: GET /obp/v7.0.0/consumers/current/scopes returns the Scopes the calling Consumer holds, without any Role.
+ |
+ |## Endpoints
+ |
+ |- [Create Platform App](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-createPlatformApp): `POST /obp/v7.0.0/management/platform-apps`, to mark a Consumer as a Platform App.
+ |- [Get Platform Apps](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getPlatformApps): `GET /obp/v7.0.0/management/platform-apps`, to list them, with each declared Scope held or not.
+ |- [Delete Platform App](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-deletePlatformApp): `DELETE /obp/v7.0.0/management/platform-apps/CONSUMER_ID`, to unmark one.
+ |- [Update Current Consumer Platform App](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-updateCurrentConsumerPlatformApp): `PUT /obp/v7.0.0/consumers/current/platform-app`, for an app to declare the Scopes it needs.
+ |- [Get Current Consumer Scopes](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-getCurrentConsumerScopes): `GET /obp/v7.0.0/consumers/current/scopes`, for an app to read the Scopes its Consumer holds.
+ |- [Create Scope for a Consumer](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-addScope): `POST /obp/v7.0.0/consumers/CONSUMER_ID/scopes`, to grant a missing Scope.
+ |""".stripMargin)
+
+ glossaryItems += GlossaryItem(
+ title = "Groups",
+ description =
+ s"""
+ |# Groups
+ |
+ |A **Group** is a named list of Roles at one bank id (or at system level), used to give the same Roles to many Users. A Group has a name, a description, its list of Roles, and whether it is enabled.
+ |
+ |A Group is not itself checked when a User calls an endpoint. Adding a User to a Group grants them the Group's Roles as ordinary Entitlements, at the Group's bank id, and those Entitlements are what every Role check reads.
+ |
+ |## Adding a User to a Group
+ |
+ |Each of the Group's Roles the User does not already hold at that bank id is granted to them (the User is emailed about each one), and the Entitlement records the Group that granted it (its `group_id`). A Role they already hold, however it was granted, is not granted again: a User holds a Role at a bank id once. The membership itself is recorded as well, so a User is a member of a Group even when the Group granted them nothing because they already held all its Roles.
+ |
+ |Only an enabled Group can have Users added to it.
+ |
+ |## Groups that share Roles
+ |
+ |Two Groups may list the same Role. A member of both holds it once, recorded against the Group that granted it first. When that Group stops granting it to the User, because the User is removed from it or the Role is taken out of it, the Role is kept if another Group the User is in, at the same bank id, still grants it: the Entitlement is then recorded against that Group instead. Nothing is emailed, because the User's Roles do not change.
+ |
+ |## Changing a Group's Roles
+ |
+ |Updating a Group changes its list of Roles, but not what its existing members hold. To bring them in line, sync the Group's members: each member is granted the Group's Roles they lack, and loses the Entitlements the Group granted for Roles it no longer has (subject to the sharing rule above). A dry run shows what would change without changing anything. Entitlements granted by hand, or by other Groups, are never touched.
+ |
+ |## Removing a User from a Group
+ |
+ |Removing a User from a Group ends the membership and deletes the Entitlements the Group granted them, except those another of their Groups still grants (see above). Deleting a Group ends all its memberships; the Entitlements it granted are left in place.
+ |
+ |## Roles
+ |
+ |Managing Groups needs CanCreateGroupAtOneBank, CanGetGroupsAtOneBank, CanUpdateGroupAtOneBank and CanDeleteGroupAtOneBank at the Group's bank id, or the AllBanks version of each (required for a system level Group). Adding and removing members needs CanAddUserToGroupAtOneBank and CanRemoveUserFromGroupAtOneBank, or their AllBanks versions; syncing a Group's members needs both.
+ |
+ |## Endpoints
+ |
+ |- [Create Group](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-createGroup): `POST /obp/v6.0.0/management/groups`
+ |- [Get Groups](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-getGroups): `GET /obp/v6.0.0/management/groups`
+ |- [Update Group](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-updateGroup): `PUT /obp/v6.0.0/management/groups/GROUP_ID`
+ |- [Delete Group](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-deleteGroup): `DELETE /obp/v6.0.0/management/groups/GROUP_ID`
+ |- [Get Group Entitlements](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-getGroupEntitlements): `GET /obp/v6.0.0/management/groups/GROUP_ID/entitlements`, the Entitlements a Group has granted.
+ |- [Add User to Group](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-addUserToGroup): `POST /obp/v6.0.0/users/USER_ID/group-entitlements`
+ |- [Remove User from Group](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-removeUserFromGroup): `DELETE /obp/v6.0.0/users/USER_ID/group-entitlements/GROUP_ID`
+ |- [Get User's Group Memberships](${apiExplorerUrl}/resource-docs/OBPv6.0.0?operationid=OBPv6.0.0-getUserGroupMemberships): `GET /obp/v6.0.0/users/USER_ID/group-entitlements`
+ |- [Sync Group Members](${apiExplorerUrl}/resource-docs/OBPv7.0.0?operationid=OBPv7.0.0-syncGroupMembers): `POST /obp/v7.0.0/management/groups/GROUP_ID/sync-members`
+ |
+ |How Roles and Entitlements control access is described ${getGlossaryItemLink("API.Access Control")}.
+ |""".stripMargin)
+
glossaryItems += GlossaryItem(
title = "Telemetry",
description =
diff --git a/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala b/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala
index 1060621d78..6738703122 100644
--- a/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala
@@ -34,6 +34,7 @@ import code.util.Helper.MdcLoggable
*
* trust.proxy.enabled = true
* trust.proxy.header = X-Real-IP # default; or "X-Forwarded-For"
+ * trust.proxy.peers = 10.0.0.5, 172.16.0.0/12 # optional; see below
*
* The proxy MUST overwrite the configured header so clients cannot spoof it. Example NGINX:
*
@@ -43,26 +44,101 @@ import code.util.Helper.MdcLoggable
* trustworthy when the proxy is configured with `set_real_ip_from` + `real_ip_recursive`
* so it sanitises the forwarded chain before forwarding upstream. `X-Real-IP` is the
* simpler choice for single-proxy deployments.
+ *
+ * `trust.proxy.peers` closes a gap: without it, the header is believed from whoever sent the
+ * request, so a caller that can reach OBP-API directly (bypassing the proxy) can name any
+ * address it likes, to slip past per-IP limits or to get someone else penalised. With it, the
+ * header is believed only when the TCP peer is one of the listed addresses or CIDR ranges;
+ * from any other peer it is ignored and the peer itself is the client. Unset, the behaviour is
+ * unchanged (the header is believed from anyone), and Deployment Checks reports it.
+ *
+ * Addresses are returned in canonical form, without the brackets http4s puts around IPv6.
*/
object RemoteIpUtil extends MdcLoggable {
+ /** How a request's client address was decided, for Deployment Checks. */
+ final case class Resolution(
+ clientIp: String,
+ socketPeer: String,
+ /** The configured header (or any forwarding header when trust is off) was present. */
+ forwardingHeaderPresent: Boolean,
+ /** The header's value became the client address. */
+ headerHonoured: Boolean,
+ /** The header was present but ignored, because its sender is not in trust.proxy.peers. */
+ headerFromUntrustedPeer: Boolean
+ )
+
+ private val ForwardingHeaders = List("X-Real-IP", "X-Forwarded-For")
+
/** Resolve the trusted client IP.
* @param socketPeer the immediate TCP peer's address (proxy IP, or real client if direct)
* @param getHeader function to read a request header by name (case-insensitive); returns
* the raw header value if present
* @return the trusted client IP — either the parsed header value or `socketPeer` as fallback
*/
- def resolveClientIp(socketPeer: String, getHeader: String => Option[String]): String = {
+ def resolveClientIp(socketPeer: String, getHeader: String => Option[String]): String =
+ resolve(socketPeer, getHeader).clientIp
+
+ /** Like [[resolveClientIp]], with the details of the decision. */
+ def resolve(socketPeer: String, getHeader: String => Option[String]): Resolution = {
+ val peer = canonical(socketPeer)
if (!APIUtil.getPropsAsBoolValue("trust.proxy.enabled", false)) {
- socketPeer
+ Resolution(peer, peer, ForwardingHeaders.exists(h => getHeader(h).exists(_.trim.nonEmpty)), headerHonoured = false, headerFromUntrustedPeer = false)
} else {
val headerName = APIUtil.getPropsValue("trust.proxy.header", "X-Real-IP")
- getHeader(headerName)
- .flatMap(raw => extractClientIp(headerName, raw))
- .getOrElse(socketPeer)
+ val fromHeader = getHeader(headerName).flatMap(raw => extractClientIp(headerName, raw)).map(canonical)
+ fromHeader match {
+ case None => Resolution(peer, peer, forwardingHeaderPresent = false, headerHonoured = false, headerFromUntrustedPeer = false)
+ case Some(_) if !peerIsTrusted(peer) => Resolution(peer, peer, forwardingHeaderPresent = true, headerHonoured = false, headerFromUntrustedPeer = true)
+ case Some(client) => Resolution(client, peer, forwardingHeaderPresent = true, headerHonoured = true, headerFromUntrustedPeer = false)
+ }
+ }
+ }
+
+ /** The configured trusted peers, as parsed CIDR ranges (a single address is a /32 or /128). */
+ def trustedPeers: List[(Array[Byte], Int)] =
+ APIUtil.getPropsValue("trust.proxy.peers").toList
+ .flatMap(_.split(",").map(_.trim).filter(_.nonEmpty))
+ .flatMap(parseCidr)
+
+ /** True when trust.proxy.peers is unset (anyone is believed) or the peer is in it. */
+ def peerIsTrusted(peer: String): Boolean = {
+ val peers = trustedPeers
+ peers.isEmpty || addressBytes(peer).exists(bytes => peers.exists { case (net, bits) => inRange(bytes, net, bits) })
+ }
+
+ private def parseCidr(value: String): Option[(Array[Byte], Int)] = {
+ val (address, bits) = value.split("/", 2) match {
+ case Array(a, b) => (a, scala.util.Try(b.trim.toInt).toOption)
+ case Array(a) => (a, None)
+ }
+ addressBytes(address).flatMap { bytes =>
+ val size = bytes.length * 8
+ val prefix = bits.getOrElse(size)
+ if (prefix < 0 || prefix > size) { logger.warn(s"RemoteIpUtil says: ignoring invalid trust.proxy.peers entry $value"); None }
+ else Some((bytes, prefix))
}
}
+ private def addressBytes(value: String): Option[Array[Byte]] = {
+ val v = canonical(value)
+ if (com.google.common.net.InetAddresses.isInetAddress(v)) Some(com.google.common.net.InetAddresses.forString(v).getAddress) else None
+ }
+
+ private def inRange(address: Array[Byte], network: Array[Byte], prefix: Int): Boolean =
+ address.length == network.length && (0 until prefix).forall { bit =>
+ val mask = 0x80 >> (bit % 8)
+ (address(bit / 8) & mask) == (network(bit / 8) & mask)
+ }
+
+ /** An address without IPv6 brackets, in canonical form when it is a literal address. */
+ def canonical(value: String): String = {
+ val unbracketed = Option(value).map(_.trim.stripPrefix("[").stripSuffix("]")).getOrElse("")
+ if (com.google.common.net.InetAddresses.isInetAddress(unbracketed))
+ com.google.common.net.InetAddresses.toAddrString(com.google.common.net.InetAddresses.forString(unbracketed))
+ else unbracketed
+ }
+
/** Single-value headers (X-Real-IP) yield the value as-is.
* X-Forwarded-For is comma-separated; the leftmost entry is the original client. */
private def extractClientIp(headerName: String, raw: String): Option[String] = {
diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
index 3a89c068a8..82e11da486 100644
--- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
+++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
@@ -226,7 +226,7 @@ object Http4sApp extends MdcLoggable {
}
.flatTap(resp => IO {
// Where the traffic is coming from: every request, served, refused or unmatched, once.
- try code.telemetry.TrafficSources.record(note, Http4sCallContextBuilder.clientIp(req), resp.status.code,
+ try code.telemetry.TrafficSources.record(note, Http4sCallContextBuilder.clientIpResolution(req), resp.status.code,
(System.nanoTime() - startNanos) / 1000000L)
catch { case e: Throwable => logger.debug(s"Http4sApp says: could not record traffic: ${e.getMessage}") }
})
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 f6a74258b5..87e63bc6dc 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
@@ -722,13 +722,14 @@ object Http4sCallContextBuilder {
* request-level middleware (SelfServiceRateLimitMiddleware) keys on the same value. */
def clientIp(request: Request[IO]): String = extractIpAddress(request)
- private def extractIpAddress(request: Request[IO]): String = {
- val socketPeer = request.remoteAddr.map(_.toUriString).getOrElse("")
- RemoteIpUtil.resolveClientIp(
- socketPeer,
+ /** How the request's client address was decided (for TrafficSources and Deployment Checks). */
+ def clientIpResolution(request: Request[IO]): RemoteIpUtil.Resolution =
+ RemoteIpUtil.resolve(
+ request.remoteAddr.map(_.toUriString).getOrElse(""),
name => request.headers.get(CIString(name)).map(_.head.value)
)
- }
+
+ private def extractIpAddress(request: Request[IO]): String = clientIpResolution(request).clientIp
/**
* Extract Authorization header value as Box[String]
diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala
index 791b014731..41b90916c3 100644
--- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala
+++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala
@@ -200,6 +200,10 @@ object ResourceDocMiddleware extends MdcLoggable {
note.consumerId = Some(consumer.consumerId.get)
note.consumerName = Some(consumer.name.get)
}
+ for {
+ note <- Http4sRequestAttributes.trafficNote(req)
+ user <- enrichedReq.attributes.lookup(Http4sRequestAttributes.callContextKey).flatMap(_.user.toOption)
+ } note.userId = Some(user.userId)
val routeIO =
routes.run(enrichedReq)
.map(ensureJsonContentType)
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 e04faeedbc..eecb91d7ba 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
@@ -2233,6 +2233,9 @@ object Http4s600 {
.map(unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404))
_ <- groupRoleCheck(group.bankId, user.userId, canAddUserToGroupAtOneBank, canAddUserToGroupAtAllBanks, cc)
_ <- Helper.booleanToFuture(s"$UnknownError Group is not enabled", 400, Some(cc))(group.isEnabled)
+ // Recorded even when every Role is skipped below: the Entitlements alone would not show it.
+ _ <- Future(code.group.GroupMemberships.addMembership(group.groupId, userIdStr, Some(user.userId)))
+ .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Cannot record the group membership", 400))
existingEntitlements <- Future(Entitlement.entitlement.vend.getEntitlementsByUserId(userIdStr))
entitlementResults <- Future.sequence(group.listOfRoles.map { roleName =>
Future {
@@ -2270,13 +2273,20 @@ object Http4s600 {
group <- Future(code.group.GroupTrait.group.vend.getGroup(groupId))
.map(unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404))
_ <- groupRoleCheck(group.bankId, user.userId, canRemoveUserFromGroupAtOneBank, canRemoveUserFromGroupAtAllBanks, cc)
+ _ <- Future(code.group.GroupMemberships.removeMembership(groupId, userIdStr))
+ .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Cannot remove the group membership", 400))
entitlements <- Future(Entitlement.entitlement.vend.getEntitlementsByUserId(userIdStr))
// group_id alone identifies group-born rows (only group grants set it) and holds
// for legacy rows too; the old `process == GROUP_MEMBERSHIP` conjunct was redundant.
groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(e =>
e.groupId == Some(groupId))
- _ <- Future.sequence(groupEntitlements.map(e =>
- Future(Entitlement.entitlement.vend.deleteEntitlement(Full(e)))))
+ // A Role another of the user's Groups also grants is kept, and recorded against that Group.
+ _ <- Future.sequence(groupEntitlements.map(e => Future {
+ code.group.GroupMemberships.otherGroupGranting(userIdStr, e.bankId, e.roleName, groupId) match {
+ case Some(other) => Entitlement.entitlement.vend.setEntitlementGroupId(e.entitlementId, other.groupId)
+ case None => Entitlement.entitlement.vend.deleteEntitlement(Full(e))
+ }
+ }))
} yield ""
}
}
@@ -4739,7 +4749,8 @@ object Http4s600 {
entitlements <- Future(code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(userId))
// group_id alone identifies group-born rows (see removeUserFromGroup).
groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(_.groupId.isDefined)
- groupIds = groupEntitlements.flatMap(_.groupId).distinct
+ // Includes Groups that granted the user nothing because they already held every Role.
+ groupIds = code.group.GroupMemberships.groupIdsOfUser(userId)
_ <- Future.sequence {
groupIds.flatMap { gid =>
code.group.GroupTrait.group.vend.getGroup(gid).toOption.map { g =>
@@ -5884,6 +5895,8 @@ object Http4s600 {
_ <- groupRoleCheck(existing.bankId, user.userId, canDeleteGroupAtOneBank, canDeleteGroupAtAllBanks, cc)
_ <- Future(code.group.GroupTrait.group.vend.deleteGroup(groupId))
.map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot delete group", 400))
+ _ <- Future(code.group.GroupMemberships.removeMembershipsOfGroup(groupId))
+ .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot delete the group's memberships", 400))
} yield ""
}
}
@@ -9668,6 +9681,9 @@ object Http4s600 {
|This endpoint creates entitlements for every Role in the Group. If the user
|already has a particular role at the same bank, that entitlement is skipped (not duplicated).
|
+ |The membership itself is recorded too, so the user is a member of the Group even when every
+ |Role was skipped.
+ |
|Each entitlement created will have:
|- group_id set to the group ID
|- process set to "GROUP_MEMBERSHIP"
@@ -9719,6 +9735,9 @@ object Http4s600 {
|Only removes entitlements with:
|- group_id matching GROUP_ID
|
+ |An entitlement for a Role that another Group the user is in (at the same bank) also grants is kept,
+ |and recorded against that Group instead.
+ |
|Requires either:
|- CanRemoveUserFromGroupAtAllBanks (for any group)
|- CanRemoveUserFromGroupAtOneBank (for groups at specific bank)
@@ -12930,7 +12949,7 @@ object Http4s600 {
"Get User's Group Memberships",
s"""Get all groups a user is a member of.
|
- |Returns groups where the user has entitlements carrying a group_id.
+ |Returns the groups the user was added to, and groups where the user has entitlements carrying a group_id.
|
|The response includes:
|- list_of_entitlements: entitlements the user currently has from this group membership
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 b255a08561..e9df03cf73 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
@@ -541,6 +541,12 @@ object Http4s700 {
UserHasMissingRoles + grantingRoles.mkString(" or "), failCode = 403, cc = Some(cc)) {
APIUtil.hasAtLeastOneEntitlement(body.bank_id, user.userId, grantingRoles)
}
+ // Bank ids are matched exactly, case included: a grant at a bank id naming no bank is a
+ // row no check will ever read. SYS is the system space of Dynamic Entities, not a bank.
+ _ <- Helper.booleanToFuture(failMsg = BankNotFound, failCode = 404, cc = Some(cc)) {
+ body.bank_id.isEmpty || body.bank_id == code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID ||
+ code.model.BankX(BankId(body.bank_id), Some(cc)).map(_._1).isDefined
+ }
_ <- Helper.booleanToFuture(failMsg = EntitlementAlreadyExists, failCode = 409, cc = Some(cc))(
!hasEntitlement(body.bank_id, userId, role))
entitlement <- Future(Entitlement.entitlement.vend.addEntitlement(
@@ -557,10 +563,11 @@ object Http4s700 {
"POST",
"/users/USER_ID/entitlements",
"Add Entitlement for a User",
- """Grant a Role to a User. Set bank_id to "" for system-level roles, or a valid bank_id for bank-level roles.""",
+ """Grant a Role to a User. Set bank_id to "" for system-level roles, or a valid bank_id for bank-level roles.
+ |The bank_id must name an existing Bank (matched exactly, case included), or be SYS, the system space of Dynamic Entities.""".stripMargin,
CreateEntitlementJSON("gh.29.uk", "CanGetAnyUser"),
EmptyBody,
- List($AuthenticatedUserIsRequired, UserNotFoundById, InvalidJsonFormat, EntitlementAlreadyExists, UnknownError),
+ List($AuthenticatedUserIsRequired, UserNotFoundById, InvalidJsonFormat, BankNotFound, EntitlementAlreadyExists, UnknownError),
apiTagEntitlement :: apiTagRole :: apiTagUser :: Nil,
Some(List(canCreateEntitlementAtOneBank, canCreateEntitlementAtAnyBank)),
http4sPartialFunction = Some(addEntitlement)
@@ -808,6 +815,107 @@ object Http4s700 {
http4sPartialFunction = Some(getCurrentConsumerIdentity)
)
+ // 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.
+ val getCurrentConsumerScopes: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "consumers" / "current" / "scopes" =>
+ EndpointHelpers.executeFuture(req) {
+ implicit val cc: CallContext = req.callContext
+ for {
+ consumer <- Future(cc.consumer match {
+ case Full(c) => Full(c)
+ case _ => net.liftweb.common.Empty
+ }).map(unboxFullOrFail(_, Some(cc), ApplicationNotIdentified, 401))
+ scopes <- Future(code.scope.Scope.scope.vend.getScopesByConsumerId(consumer.id.get.toString).openOr(Nil))
+ } yield JSONFactory700.createCurrentConsumerScopesJsonV700(consumer, scopes)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getCurrentConsumerScopes),
+ "GET",
+ "/consumers/current/scopes",
+ "Get Current Consumer Scopes",
+ s"""Returns the Roles the Consumer making this call holds as Scopes, each with its `bank_id`
+ |(a bank id, SYS for the system space, or empty for a system Role).
+ |
+ |No Role is required. The caller must be identifiable as a Consumer, either through a logged-in User (whose
+ |Consumer this is) or as an Application on its own (OAuth2 client credentials, or a Consumer Key).
+ |A call with no credentials gets ${ApplicationNotIdentified}
+ |
+ |Use it from a service (for example the Portal or the API Manager) to check that its Consumer holds the Scopes
+ |it needs. To list another Consumer's Scopes, see Get Scopes for Consumer.
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700.currentConsumerScopesJsonV700Example,
+ List(ApplicationNotIdentified, UnknownError),
+ apiTagConsumer :: apiTagScope :: apiTagApi :: Nil,
+ None,
+ authMode = UserOrApplication,
+ http4sPartialFunction = Some(getCurrentConsumerScopes)
+ )
+
+ // Route: POST /obp/v7.0.0/consumers/CONSUMER_ID/scopes (201)
+ // As v4.0.0's, with two differences: bank_id may be SYS, the system space of Dynamic Entities, where
+ // the Definition and Record Roles live (v4.0.0 refuses it as an unknown bank); and the duplicate check
+ // looks the Scope up by the Consumer's primary key, the key Scopes are stored under.
+ val addScope: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ POST -> `prefixPath` / "consumers" / consumerId / "scopes" =>
+ EndpointHelpers.withUserAndBodyCreated[code.api.v3_0_0.CreateScopeJson, AnyRef](req) { (user, body, cc) =>
+ for {
+ consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc))
+ role <- NewStyle.function.tryons(
+ s"$IncorrectRoleName ${body.role_name}. Possible roles are ${ApiRole.availableRoles.sorted.mkString(", ")}",
+ 400, Some(cc)) { ApiRole.valueOf(body.role_name) }
+ _ <- Helper.booleanToFuture(
+ failMsg = if (role.requiresBankId) EntitlementIsBankRole else EntitlementIsSystemRole,
+ cc = Some(cc))(role.requiresBankId == body.bank_id.nonEmpty)
+ // The granting Role is held at the body's bank_id (SYS included), which the middleware cannot
+ // see: the doc keeps the Roles for the catalogue but disableAutoValidateRoles.
+ grantingRoles = ApiRole.canCreateScopeAtOneBank :: ApiRole.canCreateScopeAtAnyBank :: Nil
+ _ <- if (APIUtil.isSuperAdmin(user.userId)) Future.successful(())
+ else Helper.booleanToFuture(
+ UserHasMissingRoles + grantingRoles.mkString(" or "), failCode = 403, cc = Some(cc)) {
+ APIUtil.hasAtLeastOneEntitlement(body.bank_id, user.userId, grantingRoles)
+ }
+ _ <- Helper.booleanToFuture(failMsg = BankNotFound, failCode = 404, cc = Some(cc)) {
+ body.bank_id.isEmpty || body.bank_id == code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID ||
+ code.model.BankX(BankId(body.bank_id), Some(cc)).map(_._1).isDefined
+ }
+ _ <- Helper.booleanToFuture(failMsg = EntitlementAlreadyExists, failCode = 409, cc = Some(cc)) {
+ !APIUtil.hasScope(body.bank_id, consumer.id.get.toString, role)
+ }
+ scope <- Future(code.scope.Scope.scope.vend.addScope(body.bank_id, consumer.id.get.toString, body.role_name))
+ .map(unboxFull(_))
+ } yield code.api.v3_0_0.JSONFactory300.createScopeJson(scope)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(addScope),
+ "POST",
+ "/consumers/CONSUMER_ID/scopes",
+ "Create Scope for a Consumer",
+ s"""Grant a Role to a Consumer (App), as a Scope.
+ |
+ |For a system Role (e.g. CanGetAnyUser) set `bank_id` to an empty string. For a bank Role set it to a
+ |bank id, or to SYS for the system space of Dynamic Entities, where the Definition Roles
+ |(e.g. CanGetDynamicEntityDefinitions) and the Record Roles of system entities are held.
+ |
+ |The caller needs CanCreateScopeAtAnyBank, or CanCreateScopeAtOneBank at that `bank_id`.
+ |""".stripMargin,
+ code.api.v3_0_0.CreateScopeJson("SYS", "CanGetDynamicEntityDefinitions"),
+ code.api.v3_0_0.ScopeJson("88f52c12-38ab-4c5f-8ef1-8a0f63a84a44", "CanGetDynamicEntityDefinitions", "SYS"),
+ List($AuthenticatedUserIsRequired, ConsumerNotFoundByConsumerId, InvalidJsonFormat, IncorrectRoleName,
+ EntitlementIsBankRole, EntitlementIsSystemRole, UserHasMissingRoles, BankNotFound, EntitlementAlreadyExists, UnknownError),
+ apiTagScope :: apiTagConsumer :: Nil,
+ Some(List(ApiRole.canCreateScopeAtOneBank, ApiRole.canCreateScopeAtAnyBank)),
+ http4sPartialFunction = Some(addScope)
+ ).disableAutoValidateRoles() // roles are bank-scoped by body.bank_id; checked in the handler
+
// Route: GET /obp/v7.0.0/public/password-config
// Anonymous: clients need the policy before they hold credentials, to validate
// a proposed password locally during signup or password reset. The /public
@@ -7249,9 +7357,18 @@ object Http4s700 {
// IP penalties: an operator's temporary per-minute limit on one address.
resourceDocs ++= Http4s700IpPenalties.resourceDocs
+ // Platform Apps: the Consumers this installation runs as part of its own deployment, and the Scopes they need.
+ resourceDocs ++= Http4s700PlatformApps.resourceDocs
+
+ // Groups: bring the members of a Group in line with its current Roles.
+ resourceDocs ++= Http4s700Groups.resourceDocs
+
// Where traffic is coming from: the busiest Consumers, addresses, and callers and endpoints.
resourceDocs ++= Http4s700TrafficSources.resourceDocs
+ // Deployment Checks: is this instance, and what sits in front of it, set up correctly.
+ resourceDocs ++= Http4s700DeploymentChecks.resourceDocs
+
val allRoutes: HttpRoutes[IO] = {
val sorted = resourceDocs
.sortBy(rd => -rd.requestUrl.split("/").count(_.nonEmpty))
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DeploymentChecks.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DeploymentChecks.scala
new file mode 100644
index 0000000000..a4213bee3f
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DeploymentChecks.scala
@@ -0,0 +1,104 @@
+/**
+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 cats.effect.IO
+import code.api.Constant.ApiPathZero
+import code.api.util.APIUtil.{EmptyBody, ResourceDoc}
+import code.api.util.ApiRole._
+import code.api.util.ApiTag._
+import code.api.util.ErrorMessages._
+import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers
+import code.api.util.{CustomJsonFormats, DeploymentChecks}
+import com.github.dwickern.macros.NameOf.nameOf
+import com.openbankproject.commons.ExecutionContext.Implicits.global
+import com.openbankproject.commons.util.ApiVersion
+import org.http4s._
+import org.http4s.dsl.io._
+import org.json4s.Formats
+
+import scala.collection.mutable.ArrayBuffer
+import scala.concurrent.Future
+
+/**
+ * This object holds the v7.0.0 Deployment Checks endpoint: how this instance and the applications in
+ * front of it are set up, worked out from its props and its recent traffic (see
+ * [[code.api.util.DeploymentChecks]]).
+ *
+ * It is declared in its own object to keep Http4s700's initialiser under the JVM's 64KB method limit.
+ */
+object Http4s700DeploymentChecks {
+
+ implicit val formats: Formats = CustomJsonFormats.formats
+
+ private val implementedInApiVersion = ApiVersion.v7_0_0
+ private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
+
+ val resourceDocs = ArrayBuffer[ResourceDoc]()
+
+ // Route: GET /obp/v7.0.0/management/system/diagnostics/deployment
+ lazy val getDeploymentChecks: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "system" / "diagnostics" / "deployment" =>
+ EndpointHelpers.withUser(req) { (_, _) =>
+ Future(JSONFactory700Operations.createDeploymentChecksJson(DeploymentChecks.run()))
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getDeploymentChecks),
+ "GET",
+ "/management/system/diagnostics/deployment",
+ "Get Deployment Checks",
+ s"""Checks of how this OBP-API instance, and the proxy and applications in front of it, are set up,
+ |worked out from its props and from its traffic of the last ${DeploymentChecks.WindowMinutes} minutes.
+ |
+ |The protections against scans and floods (per-IP limits, IP penalties, the busiest-callers view) depend on
+ |OBP-API seeing each caller's real address. A proxy or application set up wrongly defeats them without any
+ |error: every caller looks like the proxy, or a caller can name any address it likes. Those mistakes show in the
+ |traffic, which is what these checks look at.
+ |
+ |Each check has a `status` (OK, INFO, WARNING, ERROR, or MANUAL for something that cannot be seen from inside
+ |OBP-API), a `basis` (`observed` from traffic, `configured` from props, or `manual`), a `message`, its
+ |`evidence`, and the `props` involved. An observed check with too little traffic to judge (fewer than
+ |${DeploymentChecks.MinRequestsToJudge} requests) says so, rather than guessing.
+ |
+ |Areas: client addresses (are they passed on, used, and believed only from the proxy), applications (does any
+ |application call for many users from one address), rate limits, and observability (log level, Telemetry
+ |collection, Redis, API Metrics).
+ |
+ |The traffic evidence is this instance's own: behind a load balancer, the response describes the instance that
+ |answered (`api_instance_id`).
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700Operations.deploymentChecksJsonV700Example,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ List(apiTagSystem, apiTagApi),
+ Some(List(canGetConfig)),
+ http4sPartialFunction = Some(getDeploymentChecks)
+ )
+}
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala
index d1e2c017bc..119d4b8388 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityDefinitions.scala
@@ -114,7 +114,7 @@ object Http4s700DynamicEntityDefinitions {
// Route: GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities
lazy val getDynamicEntityDefinitions: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" =>
- EndpointHelpers.withUser(req) { (_, _) =>
+ EndpointHelpers.executeAndRespond(req) { _ =>
val bankId = DynamicEntitySpace.bankIdOrNoneForSystem(bankIdInUrl)
for {
dynamicEntities <- Future(NewStyle.function.getDynamicEntities(bankId, false))
@@ -161,6 +161,7 @@ object Http4s700DynamicEntityDefinitions {
List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
apiTagManageDynamicEntity :: apiTagApi :: Nil,
Some(canGetDynamicEntityDefinitions :: Nil),
+ authMode = UserOrApplication,
http4sPartialFunction = Some(getDynamicEntityDefinitions)
).allowSystemSpace()
@@ -254,6 +255,7 @@ object Http4s700DynamicEntityDefinitions {
DynamicEntityNotFoundByDynamicEntityId, UnknownError),
apiTagManageDynamicEntity :: apiTagApi :: Nil,
Some(canUpdateDynamicEntityDefinition :: Nil),
+ authMode = UserOrApplication,
http4sPartialFunction = Some(updateDynamicEntityDefinition)
).allowSystemSpace()
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Groups.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Groups.scala
new file mode 100644
index 0000000000..3c4cca5cf2
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Groups.scala
@@ -0,0 +1,181 @@
+/**
+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 cats.effect.IO
+import code.api.Constant
+import code.api.Constant.ApiPathZero
+import code.api.util.APIUtil.{EmptyBody, ResourceDoc}
+import code.api.util.ApiRole._
+import code.api.util.ApiTag._
+import code.api.util.ErrorMessages._
+import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers
+import code.api.util.{APIUtil, ApiRole, CustomJsonFormats}
+import code.entitlement.Entitlement
+import code.group.{GroupMemberships, GroupTrait}
+import code.users.{Users => UserVend}
+import code.util.Helper
+import com.github.dwickern.macros.NameOf.nameOf
+import com.openbankproject.commons.ExecutionContext.Implicits.global
+import com.openbankproject.commons.util.ApiVersion
+import net.liftweb.common.Full
+import org.http4s._
+import org.http4s.dsl.io._
+import org.json4s.Formats
+
+import scala.collection.mutable.ArrayBuffer
+import scala.concurrent.Future
+
+case class GroupMemberRoleMovedJsonV700(role_name: String, to_group_id: String)
+case class GroupMemberSyncJsonV700(
+ user_id: String,
+ username: String,
+ entitlements_created: List[String],
+ entitlements_deleted: List[String],
+ entitlements_moved: List[GroupMemberRoleMovedJsonV700]
+)
+case class GroupMembersSyncJsonV700(
+ group_id: String,
+ bank_id: Option[String],
+ dry_run: Boolean,
+ members: List[GroupMemberSyncJsonV700]
+)
+
+object Http4s700Groups {
+ implicit val formats: Formats = CustomJsonFormats.formats
+ private val implementedInApiVersion = ApiVersion.v7_0_0
+ private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
+ val resourceDocs = ArrayBuffer[ResourceDoc]()
+
+ private val addRoles = canAddUserToGroupAtOneBank :: canAddUserToGroupAtAllBanks :: Nil
+ private val removeRoles = canRemoveUserFromGroupAtOneBank :: canRemoveUserFromGroupAtAllBanks :: Nil
+
+ /** The caller may add users to, and remove users from, a Group at `bankId` (None: a system level Group). */
+ private def mayAddAndRemove(bankId: Option[String], userId: String): Boolean = {
+ def holds(roles: List[ApiRole]) = bankId match {
+ case Some(b) => APIUtil.hasAtLeastOneEntitlement(b, userId, roles)
+ case None => APIUtil.hasEntitlement("", userId, roles.last) // the AllBanks Role
+ }
+ APIUtil.isSuperAdmin(userId) || (holds(addRoles) && holds(removeRoles))
+ }
+
+ /** Bring one member's Entitlements in line with the Group's Roles. Changes nothing when `dryRun`. */
+ private def syncMember(group: GroupTrait, userId: String, grantedBy: String, dryRun: Boolean): GroupMemberSyncJsonV700 = {
+ val bankId = group.bankId.getOrElse("")
+ val held = Entitlement.entitlement.vend.getEntitlementsByUserId(userId).toList.flatten.filter(_.bankId == bankId)
+ val heldRoles = held.map(_.roleName).toSet
+
+ val toCreate = group.listOfRoles.filterNot(heldRoles.contains).distinct
+ val noLongerGranted = held.filter(e => e.groupId.contains(group.groupId) && !group.listOfRoles.contains(e.roleName))
+ val (toMove, toDelete) = noLongerGranted
+ .map(e => (e, GroupMemberships.otherGroupGranting(userId, bankId, e.roleName, group.groupId)))
+ .partition(_._2.isDefined)
+
+ if (!dryRun) {
+ GroupMemberships.addMembership(group.groupId, userId, Some(grantedBy))
+ toCreate.foreach(role => Entitlement.entitlement.vend.addEntitlement(
+ bankId, userId, role, Constant.group_membership, Some(grantedBy), Some(group.groupId)))
+ toMove.foreach { case (e, other) => Entitlement.entitlement.vend.setEntitlementGroupId(e.entitlementId, other.get.groupId) }
+ toDelete.foreach { case (e, _) => Entitlement.entitlement.vend.deleteEntitlement(Full(e)) }
+ }
+ GroupMemberSyncJsonV700(
+ user_id = userId,
+ username = UserVend.users.vend.getUserByUserId(userId).map(_.name).getOrElse(""),
+ entitlements_created = toCreate,
+ entitlements_deleted = toDelete.map(_._1.roleName).sorted,
+ entitlements_moved = toMove.map { case (e, other) => GroupMemberRoleMovedJsonV700(e.roleName, other.get.groupId) }
+ .sortBy(_.role_name)
+ )
+ }
+
+ // Route: POST /obp/v7.0.0/management/groups/GROUP_ID/sync-members
+ lazy val syncGroupMembers: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ POST -> `prefixPath` / "management" / "groups" / groupId / "sync-members" =>
+ EndpointHelpers.withUser(req) { (user, cc) =>
+ val dryRun = req.uri.query.params.get("dry_run").exists(_.equalsIgnoreCase("true"))
+ for {
+ group <- Future(GroupTrait.group.vend.getGroup(groupId))
+ .map(APIUtil.unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404))
+ _ <- Helper.booleanToFuture(
+ UserHasMissingRoles + addRoles.mkString(" or ") + " and " + removeRoles.mkString(" or "),
+ failCode = 403, cc = Some(cc))(mayAddAndRemove(group.bankId, user.userId))
+ _ <- Helper.booleanToFuture(s"$UnknownError Group is not enabled", 400, Some(cc))(group.isEnabled)
+ granted <- Entitlement.entitlement.vend.getEntitlementsByGroupId(groupId)
+ .map(APIUtil.unboxFullOrFail(_, Some(cc), s"$UnknownError Cannot get entitlements", 400))
+ members = GroupMemberships.userIdsOfGroup(groupId, granted)
+ synced <- Future(members.map(syncMember(group, _, user.userId, dryRun)))
+ } yield GroupMembersSyncJsonV700(group.groupId, group.bankId, dryRun, synced)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(syncGroupMembers),
+ "POST",
+ "/management/groups/GROUP_ID/sync-members",
+ "Sync Group Members",
+ s"""Bring the Entitlements of every member of a Group in line with the Group's current Roles.
+ |
+ |A Group grants its Roles when a user is added to it, so changing a Group's Roles does not change
+ |what its existing members hold. This endpoint does that, for each member:
+ |
+ |- a Role of the Group the member does not hold at the Group's bank id is granted (the member gets
+ | an email for it), recorded against this Group;
+ |- an Entitlement this Group granted, for a Role the Group no longer has, is deleted, unless another
+ | Group the member is in, at the same bank id, still grants that Role: then it is kept and recorded
+ | against that Group (no email; the member's Roles do not change).
+ |
+ |Entitlements granted by hand, or by other Groups, are not touched. Nobody is added to or removed
+ |from the Group.
+ |
+ |The members are the users added to the Group, plus the users holding an Entitlement it granted.
+ |
+ |With `dry_run=true` nothing is changed and the response says what would be.
+ |
+ |Requires CanAddUserToGroupAtOneBank or CanAddUserToGroupAtAllBanks, and
+ |CanRemoveUserFromGroupAtOneBank or CanRemoveUserFromGroupAtAllBanks (the AllBanks Roles for a
+ |system level Group).
+ |""".stripMargin,
+ EmptyBody,
+ GroupMembersSyncJsonV700(
+ group_id = "group-id-123",
+ bank_id = Some("gh.29.uk"),
+ dry_run = false,
+ members = List(GroupMemberSyncJsonV700(
+ user_id = "user-id-123",
+ username = "felixsmith",
+ entitlements_created = List("CanGetCustomer"),
+ entitlements_deleted = List("CanCreateTransaction"),
+ entitlements_moved = List(GroupMemberRoleMovedJsonV700("CanGetAccount", "group-id-456"))
+ ))
+ ),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ List(apiTagGroup, apiTagUser, apiTagEntitlement),
+ None,
+ http4sPartialFunction = Some(syncGroupMembers)
+ )
+}
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala
new file mode 100644
index 0000000000..96a24cba89
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700PlatformApps.scala
@@ -0,0 +1,270 @@
+/**
+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 cats.effect.IO
+import code.api.Constant.ApiPathZero
+import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication}
+import code.api.util.ApiRole._
+import code.api.util.ApiTag._
+import code.api.util.ErrorMessages._
+import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers
+import code.api.util.{ApiRole, CustomJsonFormats, Glossary, NewStyle}
+import code.consumer.Consumers
+import code.platformapp.{PlatformAppRequiredScopeInput, PlatformAppTrait, PlatformApps}
+import code.scope.Scope
+import code.util.Helper
+import com.github.dwickern.macros.NameOf.nameOf
+import com.openbankproject.commons.ExecutionContext.Implicits.global
+import com.openbankproject.commons.util.ApiVersion
+import net.liftweb.common.{Box, Full}
+import org.http4s._
+import org.http4s.dsl.io._
+import org.json4s.Formats
+
+import scala.collection.mutable.ArrayBuffer
+import scala.concurrent.Future
+
+/**
+ * The v7.0.0 Platform Apps endpoints: the Consumers an installation runs as part of its own deployment
+ * (see [[code.platformapp.PlatformApps]] and the Glossary item "Platform Apps").
+ *
+ * Declared in its own object to keep Http4s700's initialiser under the JVM's 64KB method limit.
+ */
+object Http4s700PlatformApps {
+
+ implicit val formats: Formats = CustomJsonFormats.formats
+
+ private val implementedInApiVersion = ApiVersion.v7_0_0
+ private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
+
+ val resourceDocs = ArrayBuffer[ResourceDoc]()
+
+ private val MaxLabelLength = 100
+ private val MaxRequiredScopes = 100
+ private val MaxNeededForLength = 1000
+ private val MaxVersionLength = 100
+
+ private def provider = PlatformApps.platformAppProvider.vend
+
+ /** The app as JSON: its declared Scopes, each marked held or not by its Consumer. */
+ private def platformAppJson(app: PlatformAppTrait): PlatformAppJsonV700 = {
+ val consumer = Consumers.consumers.vend.getConsumerByConsumerId(app.consumerId)
+ val held = consumer
+ .flatMap(c => Scope.scope.vend.getScopesByConsumerId(c.id.get.toString))
+ .openOr(Nil)
+ .map(s => (s.roleName, s.bankId))
+ JSONFactory700PlatformApps.createPlatformAppJson(
+ app,
+ consumer.map(_.name.get).openOr(""),
+ provider.getRequiredScopes(app.consumerId).openOr(Nil),
+ held)
+ }
+
+ /** A declared Scope is valid if its Role exists and its bank_id suits the Role. */
+ private def validScope(s: PlatformAppRequiredScopeJsonV700): Boolean = {
+ val role: Box[ApiRole] = net.liftweb.util.Helpers.tryo(ApiRole.valueOf(s.role_name))
+ val bankId = Option(s.bank_id).getOrElse("")
+ val neededFor = Option(s.needed_for).map(_.trim).getOrElse("")
+ role.exists(r => r.requiresBankId == bankId.nonEmpty) &&
+ neededFor.nonEmpty && neededFor.length <= MaxNeededForLength
+ }
+
+ private val platformAppsDescription =
+ s"""A Platform App is a Consumer an installation runs as part of its own deployment, for example the
+ |Portal or the API Manager, which calls OBP with its own application token. An administrator marks the
+ |Consumer as a Platform App; the app then declares, as itself, the Scopes it needs and what they are
+ |needed for, and the administrator can see which of them its Consumer holds.
+ |
+ |For more information see ${Glossary.getGlossaryItemLink("Platform Apps")}""".stripMargin
+
+ // Route: POST /obp/v7.0.0/management/platform-apps (201)
+ lazy val createPlatformApp: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ POST -> `prefixPath` / "management" / "platform-apps" =>
+ EndpointHelpers.withUserAndBodyCreated[PostPlatformAppJsonV700, PlatformAppJsonV700](req) { (user, body, cc) =>
+ val label = Option(body.label).map(_.trim).getOrElse("")
+ for {
+ _ <- Helper.booleanToFuture(InvalidPlatformApp, failCode = 400, cc = Some(cc)) {
+ label.nonEmpty && label.length <= MaxLabelLength
+ }
+ _ <- NewStyle.function.getConsumerByConsumerId(body.consumer_id, Some(cc))
+ _ <- Helper.booleanToFuture(PlatformAppAlreadyExists, failCode = 409, cc = Some(cc)) {
+ provider.getPlatformApp(body.consumer_id).isEmpty
+ }
+ app <- Future(provider.createPlatformApp(body.consumer_id, label, user.userId)) map {
+ x => fullOrFail(x, cc)
+ }
+ } yield platformAppJson(app)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(createPlatformApp),
+ "POST",
+ "/management/platform-apps",
+ "Create Platform App",
+ s"""Mark a Consumer as a Platform App, with the name administrators will know it by (`label`, 1 to
+ |$MaxLabelLength characters). Once marked, the app can declare the Scopes it needs with
+ |Update Current Consumer Platform App.
+ |
+ |$platformAppsDescription
+ |""".stripMargin,
+ JSONFactory700PlatformApps.postPlatformAppJsonV700Example,
+ JSONFactory700PlatformApps.platformAppJsonV700Example,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidPlatformApp,
+ ConsumerNotFoundByConsumerId, PlatformAppAlreadyExists, UnknownError),
+ List(apiTagConsumer, apiTagScope),
+ Some(List(canCreatePlatformApp)),
+ http4sPartialFunction = Some(createPlatformApp)
+ )
+
+ // Route: GET /obp/v7.0.0/management/platform-apps
+ lazy val getPlatformApps: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ GET -> `prefixPath` / "management" / "platform-apps" =>
+ EndpointHelpers.withUser(req) { (_, cc) =>
+ Future(provider.getPlatformApps()) map { x =>
+ PlatformAppsJsonV700(fullOrFail(x, cc).map(platformAppJson))
+ }
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(getPlatformApps),
+ "GET",
+ "/management/platform-apps",
+ "Get Platform Apps",
+ s"""The Platform Apps of this installation. For each: its Consumer, the Scopes it has declared it needs
+ |and whether its Consumer holds each (`held`), and a `state`: `ok` when every required Scope is held,
+ |`missing` when some are not (Scopes marked `optional` do not count), and `not_declared` when the app
+ |has not yet said what it needs.
+ |
+ |$platformAppsDescription
+ |""".stripMargin,
+ EmptyBody,
+ JSONFactory700PlatformApps.platformAppsJsonV700Example,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ List(apiTagConsumer, apiTagScope),
+ Some(List(canGetPlatformApps)),
+ http4sPartialFunction = Some(getPlatformApps)
+ )
+
+ // Route: DELETE /obp/v7.0.0/management/platform-apps/CONSUMER_ID (204)
+ lazy val deletePlatformApp: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ DELETE -> `prefixPath` / "management" / "platform-apps" / consumerId =>
+ EndpointHelpers.withUserDelete(req) { (_, cc) =>
+ for {
+ _ <- Helper.booleanToFuture(PlatformAppNotFound, failCode = 404, cc = Some(cc)) {
+ provider.getPlatformApp(consumerId).isDefined
+ }
+ deleted <- Future(provider.deletePlatformApp(consumerId)) map { x => fullOrFail(x, cc) }
+ } yield deleted
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(deletePlatformApp),
+ "DELETE",
+ "/management/platform-apps/CONSUMER_ID",
+ "Delete Platform App",
+ s"""Stop treating a Consumer as a Platform App, and forget the Scopes it declared. The Consumer and
+ |its Scopes are not changed.
+ |
+ |$platformAppsDescription
+ |""".stripMargin,
+ EmptyBody,
+ EmptyBody,
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, PlatformAppNotFound, UnknownError),
+ List(apiTagConsumer, apiTagScope),
+ Some(List(canDeletePlatformApp)),
+ http4sPartialFunction = Some(deletePlatformApp)
+ )
+
+ // Route: PUT /obp/v7.0.0/consumers/current/platform-app
+ // No Role: an app may always say what it needs. It is refused for a Consumer an administrator has not
+ // marked, so only the apps an administrator chose appear on the list.
+ lazy val updateCurrentConsumerPlatformApp: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ PUT -> `prefixPath` / "consumers" / "current" / "platform-app" =>
+ EndpointHelpers.executeFutureWithBody[PutPlatformAppDeclarationJsonV700, PlatformAppJsonV700](req) { (body, cc) =>
+ for {
+ consumer <- Future(cc.consumer match {
+ case Full(c) => Full(c)
+ case _ => net.liftweb.common.Empty
+ }).map(code.api.util.APIUtil.unboxFullOrFail(_, Some(cc), ApplicationNotIdentified, 401))
+ consumerId = consumer.consumerId.get
+ _ <- Helper.booleanToFuture(PlatformAppNotFound, failCode = 404, cc = Some(cc)) {
+ provider.getPlatformApp(consumerId).isDefined
+ }
+ scopes = Option(body.required_scopes).getOrElse(Nil)
+ version = body.version.map(_.trim).filter(_.nonEmpty)
+ _ <- Helper.booleanToFuture(InvalidPlatformAppDeclaration, failCode = 400, cc = Some(cc)) {
+ scopes.length <= MaxRequiredScopes && scopes.forall(validScope) &&
+ version.forall(_.length <= MaxVersionLength)
+ }
+ inputs = scopes.map(s => PlatformAppRequiredScopeInput(s.role_name, Option(s.bank_id).getOrElse(""),
+ s.needed_for.trim, s.optional.getOrElse(false)))
+ // One row per Role and bank id: a repeated Scope keeps its first declaration.
+ distinct = inputs.foldLeft(List.empty[PlatformAppRequiredScopeInput]) { (kept, s) =>
+ if (kept.exists(k => k.roleName == s.roleName && k.bankId == s.bankId)) kept else kept :+ s
+ }
+ app <- Future(provider.declareRequiredScopes(consumerId, version, distinct)) map { x => fullOrFail(x, cc) }
+ } yield platformAppJson(app)
+ }
+ }
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(updateCurrentConsumerPlatformApp),
+ "PUT",
+ "/consumers/current/platform-app",
+ "Update Current Consumer Platform App",
+ s"""Declare the Scopes the calling Consumer needs, as a Platform App: for each, the Role, its `bank_id`
+ |(a bank id, SYS for the system space of Dynamic Entities, or empty for a system Role), what it is
+ |`needed_for` (1 to $MaxNeededForLength characters, written for the administrator deciding whether
+ |to grant it) and whether it is `optional`. The declaration replaces the previous one. `version` is
+ |the app's own version, if it wants to report it.
+ |
+ |No Role is required, and an Application on its own may call it (client credentials or a Consumer
+ |Key), so an app can declare its needs at startup. The Consumer must first be marked as a Platform App
+ |by an administrator, otherwise the call gets $PlatformAppNotFound
+ |
+ |$platformAppsDescription
+ |""".stripMargin,
+ JSONFactory700PlatformApps.putPlatformAppDeclarationJsonV700Example,
+ JSONFactory700PlatformApps.platformAppJsonV700Example,
+ List(ApplicationNotIdentified, InvalidJsonFormat, PlatformAppNotFound, InvalidPlatformAppDeclaration, UnknownError),
+ List(apiTagConsumer, apiTagScope),
+ None,
+ authMode = UserOrApplication,
+ http4sPartialFunction = Some(updateCurrentConsumerPlatformApp)
+ )
+
+ private def fullOrFail[T: Manifest](box: Box[T], cc: code.api.util.CallContext): T =
+ code.api.util.APIUtil.unboxFullOrFail(box, Some(cc), UnknownError, 400)
+}
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 ec48051a30..2f8f25d4e6 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
@@ -2162,6 +2162,30 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
consumer_name = "OBP Portal"
)
+ /** 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,
+ bank_id: String
+ )
+
+ /** The calling Consumer's own Scopes. */
+ case class CurrentConsumerScopesJsonV700(
+ consumer_id: String,
+ scopes: List[CurrentConsumerScopeJsonV700]
+ )
+
+ def createCurrentConsumerScopesJsonV700(consumer: code.model.Consumer, scopes: List[code.scope.Scope]): CurrentConsumerScopesJsonV700 =
+ CurrentConsumerScopesJsonV700(
+ consumer_id = consumer.consumerId.get,
+ scopes = scopes.map(s => CurrentConsumerScopeJsonV700(role_name = s.roleName, bank_id = s.bankId))
+ .sortBy(s => (s.role_name, s.bank_id))
+ )
+
+ lazy val currentConsumerScopesJsonV700Example = CurrentConsumerScopesJsonV700(
+ consumer_id = ExampleValue.consumerIdExample.value,
+ scopes = List(CurrentConsumerScopeJsonV700(role_name = "CanGetDynamicEntityDefinitions", bank_id = "SYS"))
+ )
+
case class PasswordPolicyJsonV700(
description: String,
min_length: Int,
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Operations.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Operations.scala
index 25e9dac5eb..0a0965bbc1 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Operations.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Operations.scala
@@ -140,6 +140,31 @@ case class TrafficSourcesJsonV700(
callers_and_endpoints: List[TrafficCallerEndpointJsonV700]
)
+// ===== Deployment Checks =====
+
+case class DeploymentCheckEvidenceJsonV700(name: String, value: String)
+
+/** One check. `basis` is observed (from traffic), configured (from props) or manual (not visible from OBP-API). */
+case class DeploymentCheckJsonV700(
+ id: String,
+ title: String,
+ area: String,
+ basis: String,
+ status: String,
+ message: String,
+ evidence: List[DeploymentCheckEvidenceJsonV700],
+ props: List[String]
+)
+
+case class DeploymentChecksJsonV700(
+ api_instance_id: String,
+ checked_at: Date,
+ window_minutes: Int,
+ errors: Int,
+ warnings: Int,
+ checks: List[DeploymentCheckJsonV700]
+)
+
/** This object builds the JSON above, and holds the examples the ResourceDocs show. */
object JSONFactory700Operations {
@@ -248,6 +273,32 @@ object JSONFactory700Operations {
TrafficSourcesJsonV700(Constant.ApiInstanceId, windowMinutes, consumers, addresses, callerEndpoints)
}
+ def createDeploymentChecksJson(checks: List[code.api.util.DeploymentChecks.Check]): DeploymentChecksJsonV700 =
+ DeploymentChecksJsonV700(
+ api_instance_id = Constant.ApiInstanceId,
+ checked_at = new Date(),
+ window_minutes = code.api.util.DeploymentChecks.WindowMinutes,
+ errors = checks.count(_.status == "ERROR"),
+ warnings = checks.count(_.status == "WARNING"),
+ checks = checks.map(c => DeploymentCheckJsonV700(c.id, c.title, c.area, c.basis, c.status, c.message,
+ c.evidence.map { case (name, value) => DeploymentCheckEvidenceJsonV700(name, value) }, c.props)))
+
+ lazy val deploymentChecksJsonV700Example = DeploymentChecksJsonV700(
+ api_instance_id = "obp_4f6b3c2a-9d1e-4b7a-8c5f-2e1d0a9b8c7d",
+ checked_at = APIUtil.DateWithMsExampleObject,
+ window_minutes = 15,
+ errors = 1,
+ warnings = 0,
+ checks = List(DeploymentCheckJsonV700(
+ id = "check_client_address_forwarding", title = "Client addresses are passed on and used", area = "Client addresses",
+ basis = "observed", status = "ERROR",
+ message = "97% of requests carry a forwarding header (X-Real-IP or X-Forwarded-For), so a proxy is passing on client addresses, " +
+ "but trust.proxy.enabled is false and OBP-API ignores them.",
+ evidence = List(DeploymentCheckEvidenceJsonV700("requests (last 15 minutes)", "4210"),
+ DeploymentCheckEvidenceJsonV700("with a forwarding header", "4090 (97%)")),
+ props = List("trust.proxy.enabled", "trust.proxy.header", "trust.proxy.peers")))
+ )
+
lazy val trafficSourcesJsonV700Example = TrafficSourcesJsonV700(
api_instance_id = "obp_4f6b3c2a-9d1e-4b7a-8c5f-2e1d0a9b8c7d",
window_minutes = 5,
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700PlatformApps.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700PlatformApps.scala
new file mode 100644
index 0000000000..0f7ef57fa5
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700PlatformApps.scala
@@ -0,0 +1,120 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import java.util.Date
+
+import code.api.util.ExampleValue
+import code.platformapp.{PlatformAppRequiredScopeTrait, PlatformAppTrait}
+
+/*
+ * The JSON of the v7.0.0 Platform Apps endpoints. Package-level case classes, for the reason given in
+ * JSONFactory700Operations.
+ */
+
+/** Mark a Consumer as a Platform App. */
+case class PostPlatformAppJsonV700(consumer_id: String, label: String)
+
+/** One Scope an app declares it needs. */
+case class PlatformAppRequiredScopeJsonV700(role_name: String, bank_id: String, needed_for: String, optional: Option[Boolean])
+
+/** An app's declaration of the Scopes it needs, sent as itself. */
+case class PutPlatformAppDeclarationJsonV700(version: Option[String], required_scopes: List[PlatformAppRequiredScopeJsonV700])
+
+/** One declared Scope and whether the app's Consumer holds it. */
+case class PlatformAppScopeStatusJsonV700(role_name: String, bank_id: String, needed_for: String, optional: Boolean, held: Boolean)
+
+case class PlatformAppJsonV700(
+ consumer_id: String,
+ consumer_name: String,
+ label: String,
+ marked_by_user_id: String,
+ marked_at: Date,
+ declared_at: Option[Date],
+ version: Option[String],
+ /** ok: every required Scope is held; missing: some are not; not_declared: the app has not said what it needs. */
+ state: String,
+ required_scopes: List[PlatformAppScopeStatusJsonV700]
+)
+
+case class PlatformAppsJsonV700(platform_apps: List[PlatformAppJsonV700])
+
+object JSONFactory700PlatformApps {
+
+ def createPlatformAppJson(
+ app: PlatformAppTrait,
+ consumerName: String,
+ declared: List[PlatformAppRequiredScopeTrait],
+ held: List[(String, String)]
+ ): PlatformAppJsonV700 = {
+ val statuses = declared.map(s => PlatformAppScopeStatusJsonV700(
+ role_name = s.roleName,
+ bank_id = s.bankId,
+ needed_for = s.neededFor,
+ optional = s.isOptional,
+ held = held.contains((s.roleName, s.bankId))))
+ val state =
+ if (app.declaredAt.isEmpty) "not_declared"
+ else if (statuses.exists(s => !s.held && !s.optional)) "missing"
+ else "ok"
+ PlatformAppJsonV700(
+ consumer_id = app.consumerId,
+ consumer_name = consumerName,
+ label = app.label,
+ marked_by_user_id = app.markedByUserId,
+ marked_at = app.markedAt,
+ declared_at = app.declaredAt,
+ version = app.declaredVersion,
+ state = state,
+ required_scopes = statuses)
+ }
+
+ lazy val postPlatformAppJsonV700Example = PostPlatformAppJsonV700(
+ consumer_id = ExampleValue.consumerIdExample.value,
+ label = "Portal")
+
+ lazy val putPlatformAppDeclarationJsonV700Example = PutPlatformAppDeclarationJsonV700(
+ version = Some("1.1.0"),
+ required_scopes = List(
+ PlatformAppRequiredScopeJsonV700("CanGetDynamicEntityRecord_obp_portal_page", "SYS",
+ "Showing the pages published with App Studio at /pages, to every visitor.", Some(false))))
+
+ lazy val platformAppJsonV700Example = PlatformAppJsonV700(
+ consumer_id = ExampleValue.consumerIdExample.value,
+ consumer_name = "obp-portal-client",
+ label = "Portal",
+ marked_by_user_id = ExampleValue.userIdExample.value,
+ marked_at = new Date(),
+ declared_at = Some(new Date()),
+ version = Some("1.1.0"),
+ state = "missing",
+ required_scopes = List(
+ PlatformAppScopeStatusJsonV700("CanGetDynamicEntityRecord_obp_portal_page", "SYS",
+ "Showing the pages published with App Studio at /pages, to every visitor.", optional = false, held = false)))
+
+ lazy val platformAppsJsonV700Example = PlatformAppsJsonV700(List(platformAppJsonV700Example))
+}
diff --git a/obp-api/src/main/scala/code/entitlement/Entilement.scala b/obp-api/src/main/scala/code/entitlement/Entilement.scala
index 965f33a0a8..3015135a16 100644
--- a/obp-api/src/main/scala/code/entitlement/Entilement.scala
+++ b/obp-api/src/main/scala/code/entitlement/Entilement.scala
@@ -54,6 +54,8 @@ trait EntitlementProvider {
): Future[Box[List[Entitlement]]]
def getEntitlementsByBankId(bankId: String): Future[Box[List[Entitlement]]]
def deleteEntitlement(entitlement: Box[Entitlement]): Box[Boolean]
+ /** Record that the Entitlement is now granted by another Group. No email: the user's Roles do not change. */
+ def setEntitlementGroupId(entitlementId: String, groupId: String): Box[Entitlement]
def getEntitlements(): Box[List[Entitlement]]
def getEntitlementsByRole(roleName: String): Box[List[Entitlement]]
def getEntitlementsFuture(): Future[Box[List[Entitlement]]]
diff --git a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala
index 646a4ef6b1..830b4fbbee 100644
--- a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala
+++ b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala
@@ -169,6 +169,11 @@ object MappedEntitlementsProvider extends EntitlementProvider with MdcLoggable {
}
}
+ override def setEntitlementGroupId(entitlementId: String, groupId: String): Box[Entitlement] =
+ MappedEntitlement.find(By(MappedEntitlement.mEntitlementId, entitlementId)).flatMap { e =>
+ tryo(e.mGroupId(groupId).saveMe())
+ }
+
override def deleteDynamicEntityEntitlement(
entityName: String,
bankId: Option[String]
diff --git a/obp-api/src/main/scala/code/group/GroupMembership.scala b/obp-api/src/main/scala/code/group/GroupMembership.scala
new file mode 100644
index 0000000000..bfbc0f590d
--- /dev/null
+++ b/obp-api/src/main/scala/code/group/GroupMembership.scala
@@ -0,0 +1,116 @@
+/**
+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.group
+
+import code.entitlement.{Entitlement, EntitlementProvider}
+import code.util.UUIDString
+import net.liftweb.common.{Box, Full}
+import net.liftweb.mapper._
+import net.liftweb.util.Helpers.tryo
+
+/**
+ * Who is in which Group.
+ *
+ * A Group grants its Roles as ordinary Entitlements, each tagged with the Group's id, and an
+ * Entitlement is unique per (bank id, user, Role). So when two Groups share a Role, the user holds
+ * it once, tagged with whichever Group granted it first, and a Group whose Roles the user already
+ * held leaves no Entitlement behind at all. The Entitlements therefore cannot say reliably who is in
+ * a Group. This table does: a row is written when a user is added to a Group and removed when they
+ * are taken out of it. Entitlements stay the only thing a Role check reads.
+ *
+ * Rows only exist for users added since the table was introduced, so every question here also
+ * counts the Group ids found on the user's Entitlements (the way membership was worked out before).
+ */
+object GroupMemberships {
+
+ private def entitlements: EntitlementProvider = Entitlement.entitlement.vend
+
+ def addMembership(groupId: String, userId: String, createdByUserId: Option[String]): Box[GroupMembership] =
+ GroupMembership.find(By(GroupMembership.GroupId, groupId), By(GroupMembership.UserId, userId)) match {
+ case Full(existing) => Full(existing)
+ case _ =>
+ tryo {
+ GroupMembership.create
+ .GroupId(groupId)
+ .UserId(userId)
+ .CreatedByUserId(createdByUserId.getOrElse(""))
+ .saveMe()
+ }
+ }
+
+ def removeMembership(groupId: String, userId: String): Box[Boolean] =
+ tryo {
+ GroupMembership.findAll(By(GroupMembership.GroupId, groupId), By(GroupMembership.UserId, userId))
+ .forall(_.delete_!)
+ }
+
+ def removeMembershipsOfGroup(groupId: String): Box[Boolean] =
+ tryo {
+ GroupMembership.findAll(By(GroupMembership.GroupId, groupId)).forall(_.delete_!)
+ }
+
+ /** The ids of the Groups the user is in: membership rows, plus the Group ids on their Entitlements. */
+ def groupIdsOfUser(userId: String): List[String] = {
+ val rows = GroupMembership.findAll(By(GroupMembership.UserId, userId)).map(_.GroupId.get)
+ val tagged = entitlements.getEntitlementsByUserId(userId).toList.flatten.flatMap(_.groupId)
+ (rows ++ tagged).distinct
+ }
+
+ /** The user ids of a Group's members: membership rows, plus the users holding an Entitlement it granted. */
+ def userIdsOfGroup(groupId: String, groupEntitlements: List[Entitlement]): List[String] = {
+ val rows = GroupMembership.findAll(By(GroupMembership.GroupId, groupId)).map(_.UserId.get)
+ (rows ++ groupEntitlements.map(_.userId)).distinct
+ }
+
+ /**
+ * Another Group the user is in, at `bankId`, that grants `roleName`, other than `excludeGroupId`.
+ * An Entitlement a Group granted is moved to such a Group, rather than deleted, when the first
+ * Group stops granting the Role to this user.
+ */
+ def otherGroupGranting(userId: String, bankId: String, roleName: String, excludeGroupId: String): Option[GroupTrait] =
+ groupIdsOfUser(userId).iterator
+ .filterNot(_ == excludeGroupId)
+ .flatMap(gid => GroupTrait.group.vend.getGroup(gid).toOption)
+ .find(g => g.bankId.getOrElse("") == bankId && g.listOfRoles.contains(roleName))
+}
+
+class GroupMembership extends LongKeyedMapper[GroupMembership] with IdPK with CreatedUpdated {
+
+ def getSingleton = GroupMembership
+
+ object GroupId extends UUIDString(this)
+ object UserId extends UUIDString(this)
+ object CreatedByUserId extends UUIDString(this) {
+ override def defaultValue = ""
+ }
+}
+
+object GroupMembership extends GroupMembership with LongKeyedMetaMapper[GroupMembership] {
+ override def dbTableName = "GroupMembership"
+ override def dbIndexes = UniqueIndex(GroupId, UserId) :: Index(UserId) :: super.dbIndexes
+}
diff --git a/obp-api/src/main/scala/code/platformapp/PlatformApp.scala b/obp-api/src/main/scala/code/platformapp/PlatformApp.scala
new file mode 100644
index 0000000000..1913bd405f
--- /dev/null
+++ b/obp-api/src/main/scala/code/platformapp/PlatformApp.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.platformapp
+
+import java.util.Date
+
+import net.liftweb.common.Box
+import net.liftweb.util.SimpleInjector
+
+/**
+ * A Platform App is a Consumer an installation runs as part of its own deployment (the Portal, the
+ * API Manager, Opey, a bank's own services), calling OBP with its own application token. An
+ * administrator marks the Consumer; the app then declares, as itself, the Scopes it needs and what for.
+ * The declaration is refused for a Consumer nobody has marked, so an arbitrary Consumer cannot put
+ * itself on the list of apps an administrator is asked to grant Scopes to.
+ */
+object PlatformApps extends SimpleInjector {
+ val platformAppProvider = new Inject(() => buildOne) {}
+ def buildOne: PlatformAppProvider = PlatformAppDbProvider
+}
+
+trait PlatformAppTrait {
+ /** The Consumer's consumer_id (its public id, not its key). */
+ def consumerId: String
+ def label: String
+ def markedByUserId: String
+ def markedAt: Date
+ /** When the app last declared its required Scopes; None until it has. */
+ def declaredAt: Option[Date]
+ /** The version the app reported with its declaration, if any. */
+ def declaredVersion: Option[String]
+}
+
+trait PlatformAppRequiredScopeTrait {
+ def consumerId: String
+ def roleName: String
+ /** A bank id, SYS for the system space, or "" for a system Role. */
+ def bankId: String
+ /** The features that depend on the Scope, as the administrator would recognise them. */
+ def neededFor: String
+ def isOptional: Boolean
+}
+
+/** One Scope in an app's declaration. */
+case class PlatformAppRequiredScopeInput(roleName: String, bankId: String, neededFor: String, isOptional: Boolean)
+
+trait PlatformAppProvider {
+ def createPlatformApp(consumerId: String, label: String, markedByUserId: String): Box[PlatformAppTrait]
+ def getPlatformApp(consumerId: String): Box[PlatformAppTrait]
+ def getPlatformApps(): Box[List[PlatformAppTrait]]
+ /** Unmarks the Consumer and forgets its declaration. */
+ def deletePlatformApp(consumerId: String): Box[Boolean]
+ /** Replaces the app's declared Scopes with these. */
+ def declareRequiredScopes(consumerId: String, version: Option[String], scopes: List[PlatformAppRequiredScopeInput]): Box[PlatformAppTrait]
+ def getRequiredScopes(consumerId: String): Box[List[PlatformAppRequiredScopeTrait]]
+}
diff --git a/obp-api/src/main/scala/code/platformapp/PlatformAppTables.scala b/obp-api/src/main/scala/code/platformapp/PlatformAppTables.scala
new file mode 100644
index 0000000000..d54943f09f
--- /dev/null
+++ b/obp-api/src/main/scala/code/platformapp/PlatformAppTables.scala
@@ -0,0 +1,173 @@
+/**
+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.platformapp
+
+import java.util.Date
+
+import net.liftweb.common.{Box, Full}
+import net.liftweb.db.DB
+import net.liftweb.mapper._
+import net.liftweb.util.DefaultConnectionIdentifier
+import net.liftweb.util.Helpers.tryo
+
+object PlatformAppDbProvider extends PlatformAppProvider {
+
+ override def createPlatformApp(consumerId: String, label: String, markedByUserId: String): Box[PlatformAppTrait] =
+ tryo {
+ PlatformApp.create
+ .PlatformAppId(java.util.UUID.randomUUID().toString)
+ .ConsumerId(consumerId)
+ .Label(label)
+ .MarkedByUserId(markedByUserId)
+ .saveMe()
+ }
+
+ override def getPlatformApp(consumerId: String): Box[PlatformAppTrait] =
+ PlatformApp.find(By(PlatformApp.ConsumerId, consumerId))
+
+ override def getPlatformApps(): Box[List[PlatformAppTrait]] =
+ tryo { PlatformApp.findAll(OrderBy(PlatformApp.Label, Ascending)) }
+
+ override def deletePlatformApp(consumerId: String): Box[Boolean] =
+ PlatformApp.find(By(PlatformApp.ConsumerId, consumerId)).flatMap { app =>
+ tryo {
+ DB.use(DefaultConnectionIdentifier) { _ =>
+ PlatformAppRequiredScope.bulkDelete_!!(By(PlatformAppRequiredScope.ConsumerId, consumerId))
+ app.delete_!
+ }
+ }
+ }
+
+ override def declareRequiredScopes(
+ consumerId: String,
+ version: Option[String],
+ scopes: List[PlatformAppRequiredScopeInput]
+ ): Box[PlatformAppTrait] =
+ PlatformApp.find(By(PlatformApp.ConsumerId, consumerId)).flatMap { app =>
+ tryo {
+ // One transaction: a reader never sees the old declaration half replaced.
+ DB.use(DefaultConnectionIdentifier) { _ =>
+ PlatformAppRequiredScope.bulkDelete_!!(By(PlatformAppRequiredScope.ConsumerId, consumerId))
+ scopes.foreach { s =>
+ PlatformAppRequiredScope.create
+ .ConsumerId(consumerId)
+ .RoleName(s.roleName)
+ .BankId(s.bankId)
+ .NeededFor(s.neededFor)
+ .IsOptional(s.isOptional)
+ .saveMe()
+ }
+ app.DeclaredAt(new Date()).DeclaredVersion(version.getOrElse("")).LastUpdate(new Date()).saveMe()
+ }
+ }
+ }
+
+ override def getRequiredScopes(consumerId: String): Box[List[PlatformAppRequiredScopeTrait]] =
+ tryo {
+ PlatformAppRequiredScope.findAll(
+ By(PlatformAppRequiredScope.ConsumerId, consumerId),
+ OrderBy(PlatformAppRequiredScope.id, Ascending))
+ }
+}
+
+class PlatformApp extends PlatformAppTrait with LongKeyedMapper[PlatformApp] with IdPK {
+ def getSingleton = PlatformApp
+
+ object PlatformAppId extends MappedString(this, 36) {
+ override def dbColumnName = "platform_app_id"
+ }
+ object ConsumerId extends MappedString(this, 250) {
+ override def dbColumnName = "consumer_id"
+ }
+ object Label extends MappedString(this, 100) {
+ override def dbColumnName = "label"
+ }
+ object MarkedByUserId extends MappedString(this, 255) {
+ override def dbColumnName = "marked_by_user_id"
+ }
+ object CreationDate extends MappedDateTime(this) {
+ override def dbColumnName = "created_at"
+ override def defaultValue = new Date()
+ }
+ object LastUpdate extends MappedDateTime(this) {
+ override def dbColumnName = "updated_at"
+ override def defaultValue = new Date()
+ }
+ object DeclaredAt extends MappedDateTime(this) {
+ override def dbColumnName = "declared_at"
+ }
+ object DeclaredVersion extends MappedString(this, 100) {
+ override def dbColumnName = "declared_version"
+ }
+
+ override def consumerId: String = ConsumerId.get
+ override def label: String = Label.get
+ override def markedByUserId: String = MarkedByUserId.get
+ override def markedAt: Date = CreationDate.get
+ override def declaredAt: Option[Date] = Option(DeclaredAt.get)
+ override def declaredVersion: Option[String] = Option(DeclaredVersion.get).filter(_.nonEmpty)
+}
+
+object PlatformApp extends PlatformApp with LongKeyedMetaMapper[PlatformApp] {
+ override def dbTableName = "platform_app"
+ override def dbIndexes = UniqueIndex(ConsumerId) :: super.dbIndexes
+}
+
+class PlatformAppRequiredScope extends PlatformAppRequiredScopeTrait
+ with LongKeyedMapper[PlatformAppRequiredScope] with IdPK {
+ def getSingleton = PlatformAppRequiredScope
+
+ object ConsumerId extends MappedString(this, 250) {
+ override def dbColumnName = "consumer_id"
+ }
+ object RoleName extends MappedString(this, 255) {
+ override def dbColumnName = "role_name"
+ }
+ object BankId extends MappedString(this, 255) {
+ override def dbColumnName = "bank_id"
+ }
+ object NeededFor extends MappedString(this, 1000) {
+ override def dbColumnName = "needed_for"
+ }
+ object IsOptional extends MappedBoolean(this) {
+ override def dbColumnName = "is_optional"
+ override def defaultValue = false
+ }
+
+ override def consumerId: String = ConsumerId.get
+ override def roleName: String = RoleName.get
+ override def bankId: String = BankId.get
+ override def neededFor: String = NeededFor.get
+ override def isOptional: Boolean = IsOptional.get
+}
+
+object PlatformAppRequiredScope extends PlatformAppRequiredScope
+ with LongKeyedMetaMapper[PlatformAppRequiredScope] {
+ override def dbTableName = "platform_app_required_scope"
+ override def dbIndexes = Index(ConsumerId) :: super.dbIndexes
+}
diff --git a/obp-api/src/main/scala/code/telemetry/Telemetry.scala b/obp-api/src/main/scala/code/telemetry/Telemetry.scala
index ce741038c1..e4d361dade 100644
--- a/obp-api/src/main/scala/code/telemetry/Telemetry.scala
+++ b/obp-api/src/main/scala/code/telemetry/Telemetry.scala
@@ -188,6 +188,10 @@ object Telemetry {
port = APIUtil.getPropsAsIntValue("telemetry.port", DefaultPort))
}
+ /** When Prometheus (or anything) last collected Telemetry from the separate port; None if never since start-up. */
+ @volatile private var lastScrapeAt: Option[Long] = None
+ def lastScrapeMillis: Option[Long] = lastScrapeAt
+
/** The port actually bound, when the separate port is open. */
def boundPort: Option[Int] = server.map(_.getAddress.getPort)
@@ -228,7 +232,7 @@ object Telemetry {
try {
val (status, body, contentType) =
if (exchange.getRequestURI.getPath == ScrapePath && exchange.getRequestMethod == "GET")
- (200, scrape(), "text/plain; version=0.0.4; charset=utf-8")
+ { lastScrapeAt = Some(System.currentTimeMillis()); (200, scrape(), "text/plain; version=0.0.4; charset=utf-8") }
else
(404, s"Not found. Telemetry is served at $ScrapePath\n", "text/plain; charset=utf-8")
val bytes = body.getBytes(StandardCharsets.UTF_8)
diff --git a/obp-api/src/main/scala/code/telemetry/TrafficSources.scala b/obp-api/src/main/scala/code/telemetry/TrafficSources.scala
index c06839b657..988d2135eb 100644
--- a/obp-api/src/main/scala/code/telemetry/TrafficSources.scala
+++ b/obp-api/src/main/scala/code/telemetry/TrafficSources.scala
@@ -58,6 +58,9 @@ object TrafficSources {
val MinutesKept = 15
val EndpointsKeptPerCaller = 32
val ConsumersKeptPerAddress = 8
+ val AddressesKeptPerConsumer = 16
+ val UsersKeptPerConsumer = 16
+ val PeersKeptPerMinute = 64
// ===== What inner layers tell the recording point =====
@@ -67,6 +70,7 @@ object TrafficSources {
@volatile var apiVersion: Option[String] = None
@volatile var consumerId: Option[String] = None
@volatile var consumerName: Option[String] = None
+ @volatile var userId: Option[String] = None
@volatile var refusedBy: Option[String] = None
}
@@ -97,6 +101,10 @@ object TrafficSources {
final class ConsumerDetails extends StatusCounts {
var name = ""
val endpoints = mutable.LinkedHashSet.empty[String]
+ // For Deployment Checks: an application calling for many users from one address does not pass
+ // on its users' addresses.
+ val addresses = mutable.LinkedHashSet.empty[String]
+ val users = mutable.LinkedHashSet.empty[String]
var lastIp = ""
var firstSeen = 0L; var lastSeen = 0L
}
@@ -113,12 +121,35 @@ object TrafficSources {
var lastSeen = 0L
}
+ /**
+ * How the minute's client addresses were decided (for Deployment Checks): whether a forwarding
+ * header came with the request, whether it was believed, and which TCP peers sent requests.
+ */
+ final class ForwardingCounts {
+ var requests = 0L
+ var withForwardingHeader = 0L
+ var headerHonoured = 0L
+ var headerFromUntrustedPeer = 0L
+ val peers = mutable.LinkedHashSet.empty[String]
+ val peersSendingHeader = mutable.LinkedHashSet.empty[String]
+
+ def add(resolution: code.api.util.RemoteIpUtil.Resolution): Unit = synchronized {
+ requests += 1
+ if (resolution.forwardingHeaderPresent) withForwardingHeader += 1
+ if (resolution.headerHonoured) headerHonoured += 1
+ if (resolution.headerFromUntrustedPeer) headerFromUntrustedPeer += 1
+ addCapped(peers, resolution.socketPeer, PeersKeptPerMinute)
+ if (resolution.forwardingHeaderPresent) addCapped(peersSendingHeader, resolution.socketPeer, PeersKeptPerMinute)
+ }
+ }
+
// ===== One minute =====
final class Minute(val startMillis: Long) {
val consumers = new HeavyHitters[String, ConsumerDetails](ConsumerSlots, () => new ConsumerDetails)
val addresses = new HeavyHitters[String, AddressDetails](AddressSlots, () => new AddressDetails)
val callerEndpoints = new HeavyHitters[(Caller, String), CallerEndpointDetails](CallerEndpointSlots, () => new CallerEndpointDetails)
+ val forwarding = new ForwardingCounts
}
private def minuteStart(millis: Long): Long = millis - millis % 60000L
@@ -149,8 +180,11 @@ object TrafficSources {
* response's. A request counts under its Consumer when one was authenticated, and always under its
* client address.
*/
- def record(note: Note, ipAddress: String, status: Int, durationMillis: Long, now: Long = System.currentTimeMillis()): Unit = {
+ def record(note: Note, resolution: code.api.util.RemoteIpUtil.Resolution, status: Int, durationMillis: Long,
+ now: Long = System.currentTimeMillis()): Unit = {
val minute = minuteFor(now)
+ minute.forwarding.add(resolution)
+ val ipAddress = resolution.clientIp
val isRefused = note.refusedBy.isDefined || status == 429
val endpoint = note.refusedBy.map(limiter => s"refused:$limiter")
.orElse(note.operationId)
@@ -164,6 +198,8 @@ object TrafficSources {
note.consumerName.foreach(d.name = _)
addCapped(d.endpoints, endpoint, EndpointsKeptPerCaller)
d.lastIp = address
+ addCapped(d.addresses, address, AddressesKeptPerConsumer)
+ note.userId.foreach(addCapped(d.users, _, UsersKeptPerConsumer))
if (d.firstSeen == 0L) d.firstSeen = now
d.lastSeen = now
}
@@ -225,6 +261,10 @@ object TrafficSources {
def callerEndpoints(minutesBack: Int, now: Long = System.currentTimeMillis()): List[Merged[(Caller, String), CallerEndpointDetails]] =
merge(window(minutesBack, now).map(_.callerEndpoints))
+ /** The forwarding counts of the window's minutes, newest first. */
+ def forwarding(minutesBack: Int, now: Long = System.currentTimeMillis()): List[ForwardingCounts] =
+ window(minutesBack, now).map(_.forwarding)
+
/** Forget everything (tests). */
def clear(): Unit = minutes.set(Nil)
}
diff --git a/obp-api/src/main/scala/code/users/UserReference.scala b/obp-api/src/main/scala/code/users/UserReference.scala
index 9be9a92dbd..9eb864035f 100644
--- a/obp-api/src/main/scala/code/users/UserReference.scala
+++ b/obp-api/src/main/scala/code/users/UserReference.scala
@@ -159,12 +159,15 @@ object UserReference {
case object AuthUser_User extends UserReference(UseAuthenticatedUserId, "code.model.dataAccess.AuthUser", List("user"), "login row -> its own ResourceUser; not attribution")
case object OpenIDConnectToken_AuthUserPrimaryKey extends UserReference(UseAuthenticatedUserId, "code.token.OpenIDConnectToken", List("AuthUserPrimaryKey"), "token belongs to the login; not attribution")
case object UserRefreshes_UserId extends UserReference(UseAuthenticatedUserId, "code.UserRefreshes.MappedUserRefreshes", List("mUserId"), "operational: refresh of the authenticated user's own account list")
+ case object PlatformApp_MarkedByUserId extends UserReference(UseAuthenticatedUserId, "code.platformapp.PlatformApp", List("MarkedByUserId"), "audit: who marked the Consumer as a platform app")
+ case object GroupMembership_CreatedByUserId extends UserReference(UseAuthenticatedUserId, "code.group.GroupMembership", List("CreatedByUserId"), "audit: who added the member, as Entitlement_GrantedByUserId")
// ---- UseOnBehalfOfUserId: the row belongs to the person, so it must outlive the Consent that
// ---- created it. A handful of these tables keep both ids, and those name two fields.
case object TransactionRequest_UserId extends UserReference(UseOnBehalfOfUserId , "code.transactionrequests.MappedTransactionRequest", List("mUserId", "mOnBehalfOfUserId"), "record both: mUserId = userId, mOnBehalfOfUserId = onBehalfOfUserId")
case object ExpectedChallengeAnswer_ExpectedUserId_TransactionRequest extends UserReference(UseOnBehalfOfUserId, "code.transactionChallenge.MappedExpectedChallengeAnswer", List("ExpectedUserId"), "payment SCA: the challenge belongs to the human whose money moves, never to the agent that started the payment")
case object Entitlement_UserId extends UserReference(UseOnBehalfOfUserId , "code.entitlement.MappedEntitlement", List("mUserId"), "the role holder; the consent-engine case is Entitlement_UserId_ConsentScope")
+ case object GroupMembership_UserId extends UserReference(UseOnBehalfOfUserId , "code.group.GroupMembership", List("UserId"), "the member, who holds the Roles the Group grants; as Entitlement_UserId")
case object AccountHolders_User extends UserReference(UseOnBehalfOfUserId , "code.accountholders.MapperAccountHolders", List("user"), "the human holds the account; one held by a per-consent identity strands when the consent dies")
case object UserCustomerLink_UserId extends UserReference(UseOnBehalfOfUserId , "code.usercustomerlinks.MappedUserCustomerLink", List("mUserId"), "a Customer is linked to a human; a link on an agent identity dies with its Consent")
case object AccountApplication_UserId extends UserReference(UseOnBehalfOfUserId , "code.accountapplication.MappedAccountApplication", List("mUserId"), "explicit target: user_id comes from the request and is guarded at the endpoint, so the provider redirect is unreachable -- see ON_BEHALF_OF_USER_ID_PLAN.md row 14")
@@ -247,8 +250,11 @@ object UserReference {
AuthUser_User,
OpenIDConnectToken_AuthUserPrimaryKey,
UserRefreshes_UserId,
+ PlatformApp_MarkedByUserId,
+ GroupMembership_CreatedByUserId,
TransactionRequest_UserId,
Entitlement_UserId,
+ GroupMembership_UserId,
AccountHolders_User,
UserCustomerLink_UserId,
AccountApplication_UserId,
diff --git a/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala
index e6177f39b6..fedadf915a 100644
--- a/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala
+++ b/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala
@@ -171,6 +171,9 @@ class OnBehalfOfOwnershipSweepTest extends ServerSetupWithTestData with DefaultU
"AccountAccessRequest_TargetUserId" ->
("explicit target, so the endpoint refuses a consent user rather than redirecting -- covered " +
"by ExplicitTargetConsentUserSweepTest. The provider redirect is unreachable from the API."),
+ "GroupMembership_UserId" ->
+ ("the member is never the caller: the Group member sync endpoint reads each member's user id " +
+ "from the Group's existing Entitlements and membership rows, so there is nothing to redirect."),
"Consent_UserId" ->
("Reject, not yet enforced: a consent user can still create a Consent (nested delegation). " +
"attributionOf already returns Failure for it -- AgentDelegationTest pins that -- but no " +
diff --git a/obp-api/src/test/scala/code/api/util/DeploymentChecksTest.scala b/obp-api/src/test/scala/code/api/util/DeploymentChecksTest.scala
new file mode 100644
index 0000000000..dc9ea15554
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/util/DeploymentChecksTest.scala
@@ -0,0 +1,77 @@
+package code.api.util
+
+import code.api.util.RemoteIpUtil.Resolution
+import code.setup.ServerSetup
+import code.telemetry.TrafficSources
+import code.telemetry.TrafficSources.Note
+
+/**
+ * This suite feeds TrafficSources with chosen traffic and checks that DeploymentChecks finds the
+ * set-up mistakes it is meant to find.
+ */
+class DeploymentChecksTest extends ServerSetup {
+
+ private def check(id: String) = DeploymentChecks.run().find(_.id == id).getOrElse(fail(s"no check $id"))
+
+ private def through(peer: String, header: Boolean) = Resolution(peer, peer, forwardingHeaderPresent = header, headerHonoured = false, headerFromUntrustedPeer = false)
+
+ private def anonymous = new Note
+
+ feature("Deployment Checks") {
+
+ scenario("too little traffic is reported as such, not guessed about") {
+ TrafficSources.clear()
+ setPropsValues("trust.proxy.enabled" -> "false")
+ check("check_client_address_forwarding").status shouldBe "INFO"
+ }
+
+ scenario("a proxy passing on addresses that OBP-API ignores is an error") {
+ TrafficSources.clear()
+ setPropsValues("trust.proxy.enabled" -> "false")
+ (1 to 30).foreach(_ => TrafficSources.record(anonymous, through("10.0.0.5", header = true), 200, 3))
+ val forwarding = check("check_client_address_forwarding")
+ forwarding.status shouldBe "ERROR"
+ forwarding.message should include("trust.proxy.enabled is false")
+ Then("and one private address carrying all the traffic is flagged too")
+ check("check_address_concentration").status shouldBe "WARNING"
+ }
+
+ scenario("believing the header from anyone is a warning; a peer list clears it") {
+ setPropsValues("trust.proxy.enabled" -> "true", "trust.proxy.peers" -> "")
+ check("check_trusted_proxy_peers").status shouldBe "WARNING"
+ setPropsValues("trust.proxy.enabled" -> "true", "trust.proxy.peers" -> "10.0.0.0/8")
+ TrafficSources.clear()
+ check("check_trusted_proxy_peers").status shouldBe "OK"
+ setPropsValues("trust.proxy.enabled" -> "false", "trust.proxy.peers" -> "")
+ }
+
+ scenario("an application calling for many users from one address is named") {
+ TrafficSources.clear()
+ (1 to 6).foreach { i =>
+ val note = new Note
+ note.consumerId = Some("consumer-explorer")
+ note.consumerName = Some("Explorer probe")
+ note.userId = Some(s"user-$i")
+ TrafficSources.record(note, through("10.0.0.9", header = false), 200, 3)
+ }
+ val applications = check("check_applications_pass_on_addresses")
+ applications.status shouldBe "WARNING"
+ applications.evidence.map(_._2).mkString should include("Explorer probe")
+ }
+
+ scenario("a DEBUG root log level is an error") {
+ val root = org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).asInstanceOf[ch.qos.logback.classic.Logger]
+ val original = root.getLevel
+ try {
+ root.setLevel(ch.qos.logback.classic.Level.DEBUG)
+ check("check_root_log_level").status shouldBe "ERROR"
+ root.setLevel(ch.qos.logback.classic.Level.INFO)
+ check("check_root_log_level").status shouldBe "OK"
+ } finally root.setLevel(original)
+ }
+
+ scenario("limits outside OBP-API are a manual item, never guessed") {
+ check("check_edge_rate_limits").status shouldBe "MANUAL"
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/util/GlossaryLinksTest.scala b/obp-api/src/test/scala/code/api/util/GlossaryLinksTest.scala
new file mode 100644
index 0000000000..62669fb277
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/util/GlossaryLinksTest.scala
@@ -0,0 +1,63 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.util
+
+import org.scalatest.{FlatSpec, Matchers}
+
+/** Links in the Glossary to the API Explorer's own pages are served fully qualified. */
+class GlossaryLinksTest extends FlatSpec with Matchers {
+
+ private val explorer = Glossary.apiExplorerUrl
+
+ "qualifyExplorerLinks" should "prefix links to the API Explorer's pages with its address" in {
+ Glossary.qualifyExplorerLinks("see [here](/glossary#Platform%20Apps) and [it](/resource-docs/OBPv7.0.0?operationid=x)") shouldBe
+ s"see [here]($explorer/glossary#Platform%20Apps) and [it]($explorer/resource-docs/OBPv7.0.0?operationid=x)"
+ Glossary.qualifyExplorerLinks("""docs and [bank](/?version=OBPv4.0.0)""") shouldBe
+ s"""docs and [bank]($explorer/?version=OBPv4.0.0)"""
+ }
+
+ it should "leave external and already qualified links alone" in {
+ val text = s"[akka](https://akka.io/) and [again]($explorer/glossary#API) and [anchor](#Onboarding)"
+ Glossary.qualifyExplorerLinks(text) shouldBe text
+ }
+
+ it should "leave a path that is not one of the API Explorer's pages alone" in {
+ Glossary.qualifyExplorerLinks("[x](/glossaryish) [y](/banks)") shouldBe "[x](/glossaryish) [y](/banks)"
+ }
+
+ "getGlossaryItemLink" should "expand to a fully qualified link, with spaces encoded" in {
+ Glossary.expandGlossaryPlaceholders(Glossary.getGlossaryItemLink("Platform Apps")) shouldBe
+ s"[here]($explorer/glossary#Platform%20Apps)"
+ }
+
+ "every served Glossary Item" should "have no site-relative link to the API Explorer" in {
+ val relative = Glossary.glossaryItems.toList.filter(i => """\]\(/(glossary|index|resource-docs|message-docs|operationid)\b""".r
+ .findFirstIn(i.description()).isDefined).map(_.title)
+ relative shouldBe empty
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/util/RemoteIpUtilTest.scala b/obp-api/src/test/scala/code/api/util/RemoteIpUtilTest.scala
new file mode 100644
index 0000000000..b77667c7d8
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/util/RemoteIpUtilTest.scala
@@ -0,0 +1,50 @@
+package code.api.util
+
+import code.setup.ServerSetup
+
+/**
+ * This suite checks how RemoteIpUtil decides a request's client address: the TCP peer by default,
+ * the forwarding header when trust.proxy.enabled is true, and, with trust.proxy.peers set, the
+ * header only from those peers.
+ */
+class RemoteIpUtilTest extends ServerSetup {
+
+ private def headers(values: (String, String)*): String => Option[String] =
+ name => values.find(_._1.equalsIgnoreCase(name)).map(_._2)
+
+ feature("RemoteIpUtil.resolve") {
+
+ scenario("with trust off, the TCP peer is the client, and a forwarding header is noted but ignored") {
+ setPropsValues("trust.proxy.enabled" -> "false")
+ val r = RemoteIpUtil.resolve("10.0.0.5", headers("X-Real-IP" -> "203.0.113.9"))
+ r.clientIp shouldBe "10.0.0.5"
+ r.forwardingHeaderPresent shouldBe true
+ r.headerHonoured shouldBe false
+ }
+
+ scenario("with trust on and no peer list, the header is believed from anyone") {
+ setPropsValues("trust.proxy.enabled" -> "true", "trust.proxy.header" -> "X-Real-IP", "trust.proxy.peers" -> "")
+ val r = RemoteIpUtil.resolve("198.51.100.77", headers("X-Real-IP" -> "203.0.113.9"))
+ r.clientIp shouldBe "203.0.113.9"
+ r.headerHonoured shouldBe true
+ }
+
+ scenario("with a peer list, the header is believed only from those peers") {
+ setPropsValues("trust.proxy.enabled" -> "true", "trust.proxy.header" -> "X-Real-IP", "trust.proxy.peers" -> "10.0.0.0/8, 2001:db8::/32")
+ RemoteIpUtil.resolve("10.1.2.3", headers("X-Real-IP" -> "203.0.113.9")).clientIp shouldBe "203.0.113.9"
+ RemoteIpUtil.resolve("[2001:db8::5]", headers("X-Real-IP" -> "203.0.113.9")).clientIp shouldBe "203.0.113.9"
+
+ val direct = RemoteIpUtil.resolve("198.51.100.77", headers("X-Real-IP" -> "203.0.113.9"))
+ direct.clientIp shouldBe "198.51.100.77"
+ direct.headerFromUntrustedPeer shouldBe true
+ direct.headerHonoured shouldBe false
+ }
+
+ scenario("X-Forwarded-For gives its leftmost address, and IPv6 comes back without brackets, in canonical form") {
+ setPropsValues("trust.proxy.enabled" -> "true", "trust.proxy.header" -> "X-Forwarded-For", "trust.proxy.peers" -> "")
+ RemoteIpUtil.resolve("10.0.0.5", headers("X-Forwarded-For" -> "203.0.113.9, 10.0.0.1")).clientIp shouldBe "203.0.113.9"
+ setPropsValues("trust.proxy.enabled" -> "false")
+ RemoteIpUtil.resolve("[2001:DB8:0:0:0:0:0:1]", headers()).clientIp shouldBe "2001:db8::1"
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerScopesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerScopesTest.scala
new file mode 100644
index 0000000000..e5651144bc
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/CurrentConsumerScopesTest.scala
@@ -0,0 +1,123 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.{canCreateScopeAtAnyBank, canGetDynamicEntityDefinitions}
+import code.api.util.ErrorMessages.{ApplicationNotIdentified, EntitlementAlreadyExists, UserHasMissingRoles}
+import code.entitlement.Entitlement
+import code.api.v6_0_0.V600ServerSetup
+import code.api.v7_0_0.JSONFactory700.{CurrentConsumerScopeJsonV700, CurrentConsumerScopesJsonV700}
+import code.scope.Scope
+import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.util.ApiVersion
+import org.json4s.JsonDSL._
+import org.json4s.native.JsonMethods.{compact, render}
+import org.scalatest.Tag
+
+/** GET /obp/v7.0.0/consumers/current/scopes: the caller's own Consumer's Scopes, no Role. */
+class CurrentConsumerScopesTest 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("getCurrentConsumerScopes")
+ object ApiEndpoint2 extends Tag("addScope")
+
+ private def scopesPath = v7_0_0_Request / "consumers" / "current" / "scopes"
+
+ feature(s"test $ApiEndpoint1 version $VersionOfApi") {
+
+ scenario("Without any credentials the application cannot be identified", ApiEndpoint1, VersionOfApi) {
+ val response = makeGetRequest(scopesPath.GET)
+ Then("We should get a 401")
+ response.code should equal(401)
+ response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified)
+ }
+
+ scenario("A caller sees the Scopes of the Consumer they called with, and only those", ApiEndpoint1, VersionOfApi) {
+ Given("testConsumer2 holds a Scope at SYS")
+ val granted = Scope.scope.vend.addScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString,
+ canGetDynamicEntityDefinitions.toString)
+ try {
+ When("user2, who signs with testConsumer2, asks")
+ val response = makeGetRequest(scopesPath.GET <@ (user2))
+ Then("the Scope is listed, with its bank id")
+ response.code should equal(200)
+ val body = response.body.extract[CurrentConsumerScopesJsonV700]
+ body.consumer_id should equal(testConsumer2.consumerId.get)
+ body.scopes should contain(CurrentConsumerScopeJsonV700(canGetDynamicEntityDefinitions.toString, DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID))
+
+ When("user1, who signs with testConsumer, asks")
+ val other = makeGetRequest(scopesPath.GET <@ (user1))
+ Then("testConsumer2's Scope is not among testConsumer's")
+ other.code should equal(200)
+ val otherBody = other.body.extract[CurrentConsumerScopesJsonV700]
+ otherBody.consumer_id should equal(testConsumer.consumerId.get)
+ otherBody.scopes should not contain CurrentConsumerScopeJsonV700(canGetDynamicEntityDefinitions.toString, DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)
+ } finally Scope.scope.vend.deleteScope(granted)
+ }
+ }
+
+ feature(s"test $ApiEndpoint2 version $VersionOfApi") {
+
+ scenario("A Scope can be granted at SYS, once, by a caller holding CanCreateScopeAtAnyBank", ApiEndpoint2, VersionOfApi) {
+ val path = v7_0_0_Request / "consumers" / testConsumer2.consumerId.get / "scopes"
+ val body = compact(render(("bank_id" -> DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) ~ ("role_name" -> canGetDynamicEntityDefinitions.toString)))
+ def existing = Scope.scope.vend.getScope(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, testConsumer2.id.get.toString,
+ canGetDynamicEntityDefinitions.toString)
+ existing.foreach(s => Scope.scope.vend.deleteScope(net.liftweb.common.Full(s)))
+
+ When("user1 has no granting Role")
+ val refused = makePostRequest(path.POST <@ (user1), body)
+ Then("the call is refused")
+ refused.code should equal(403)
+ refused.body.extract[ErrorMessage].message should include(UserHasMissingRoles)
+
+ Given("user1 holds CanCreateScopeAtAnyBank")
+ val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateScopeAtAnyBank.toString)
+ try {
+ When("user1 grants the Scope at SYS")
+ val created = makePostRequest(path.POST <@ (user1), body)
+ Then("it is created at SYS, which v4.0.0 refuses as an unknown bank")
+ created.code should equal(201)
+ (created.body \ "bank_id").extract[String] should equal(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)
+ existing.isDefined should equal(true)
+
+ When("the same Scope is granted again")
+ val again = makePostRequest(path.POST <@ (user1), body)
+ Then("it is refused as a duplicate")
+ again.code should equal(409)
+ again.body.extract[ErrorMessage].message should include(EntitlementAlreadyExists)
+ } finally {
+ existing.foreach(s => Scope.scope.vend.deleteScope(net.liftweb.common.Full(s)))
+ entitlement.foreach(e => Entitlement.entitlement.vend.deleteEntitlement(net.liftweb.common.Full(e)))
+ }
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DeploymentChecksEndpointTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DeploymentChecksEndpointTest.scala
new file mode 100644
index 0000000000..8c4fc914bd
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/DeploymentChecksEndpointTest.scala
@@ -0,0 +1,47 @@
+package code.api.v7_0_0
+
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.CanGetConfig
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles}
+import code.api.v6_0_0.V600ServerSetup
+import code.entitlement.Entitlement
+import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.util.ApiVersion
+import org.scalatest.Tag
+
+/** This suite checks GET /obp/v7.0.0/management/system/diagnostics/deployment. */
+class DeploymentChecksEndpointTest extends V600ServerSetup {
+
+ def v7_0_0_Request = baseRequest / "obp" / "v7.0.0"
+
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object ApiEndpoint extends Tag("getDeploymentChecks")
+
+ private def deployment = v7_0_0_Request / "management" / "system" / "diagnostics" / "deployment"
+
+ feature(s"Get Deployment Checks - $VersionOfApi") {
+
+ scenario("anonymous is 401, without CanGetConfig is 403", ApiEndpoint, VersionOfApi) {
+ val anonymous = makeGetRequest(deployment.GET)
+ anonymous.code should equal(401)
+ anonymous.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired)
+ val noRole = makeGetRequest(deployment.GET <@ (user1))
+ noRole.code should equal(403)
+ noRole.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetConfig)
+ }
+
+ scenario("with CanGetConfig every check comes back with a status and a basis", ApiEndpoint, VersionOfApi) {
+ val entitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetConfig.toString)
+ val response = try makeGetRequest(deployment.GET <@ (user1)) finally Entitlement.entitlement.vend.deleteEntitlement(entitlement)
+ response.code should equal(200)
+ val checks = response.body.extract[DeploymentChecksJsonV700]
+ checks.checks.map(_.id) should contain allOf ("check_client_address_forwarding", "check_trusted_proxy_peers",
+ "check_applications_pass_on_addresses", "check_root_log_level", "check_edge_rate_limits")
+ checks.checks.foreach { c =>
+ List("OK", "INFO", "WARNING", "ERROR", "MANUAL") should contain(c.status)
+ List("observed", "configured", "manual") should contain(c.basis)
+ }
+ checks.errors should equal(checks.checks.count(_.status == "ERROR"))
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala
index 4fdb077f46..2c6cf0bc48 100644
--- a/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala
+++ b/obp-api/src/test/scala/code/api/v7_0_0/DynamicEntityDefinitionTest.scala
@@ -34,6 +34,7 @@ import code.api.util.{ApiRole, DiagnosticDynamicEntityCheck}
import code.api.util.ErrorMessages._
import code.dynamicEntity.DynamicEntityProvider
import code.entitlement.Entitlement
+import code.scope.Scope
import code.setup.ServerSetupWithTestData
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.ErrorMessage
@@ -201,6 +202,38 @@ class DynamicEntityDefinitionTest extends ServerSetupWithTestData {
errorOf(response) should include(DynamicEntityNotFoundByDynamicEntityId)
} finally cascadeDelete(testBankId1.value, dynamicEntityId)
}
+
+ scenario("a Consumer holding the Role as a Scope may list and update definitions", ApiEndpoint1, ApiEndpoint3, VersionOfApi) {
+ // A service (the Portal, the API Manager) ensures its entities at startup with a client-credentials
+ // token: no User, so the Roles must be accepted as Scopes on its Consumer (auth mode UserOrApplication).
+ // user2 holds none of these Roles and signs with testConsumer2, which gets the Scopes.
+ val entityName = newEntityName()
+ val dynamicEntityId = createdAt(SYS, entityName)
+ try {
+ When("user2 lists the definitions at SYS with neither an Entitlement nor a Scope")
+ val refused = makeGetRequest(definitionsAt(SYS).GET <@ (user2))
+ Then("the call is refused for the missing Role")
+ refused.code should equal(403)
+ errorOf(refused) should include(CanGetDynamicEntityDefinitions.toString)
+
+ Given("testConsumer2 holds the Get and Update Roles as Scopes at SYS")
+ val scopes = List(canGetDynamicEntityDefinitions, canUpdateDynamicEntityDefinition).map(role =>
+ Scope.scope.vend.addScope(SYS, testConsumer2.id.get.toString, role.toString))
+ try {
+ When("user2 lists them again")
+ val listed = makeGetRequest(definitionsAt(SYS).GET <@ (user2))
+ Then("the Scope is enough")
+ listed.code should equal(200)
+ (listed.body \ "dynamic_entities").extract[List[JObject]]
+ .map(e => (e \ "entity_name").extract[String]) should contain(entityName)
+
+ When("user2 updates the definition")
+ val updated = makePutRequest((definitionsAt(SYS) / dynamicEntityId).PUT <@ (user2), write(definition(entityName)))
+ Then("the Scope is enough for that too")
+ updated.code should equal(200)
+ } finally scopes.foreach(scope => Scope.scope.vend.deleteScope(scope))
+ } finally cascadeDelete(SYS, dynamicEntityId)
+ }
}
feature("Dynamic Entity definitions at a bank") {
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala
new file mode 100644
index 0000000000..e40a0ac799
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala
@@ -0,0 +1,163 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole
+import code.api.util.ApiRole._
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles}
+import code.api.v6_0_0.V600ServerSetup
+import code.entitlement.Entitlement
+import code.group.{GroupMemberships, GroupTrait}
+import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.util.ApiVersion
+import org.json4s.JsonDSL._
+import org.json4s.native.JsonMethods.{compact, render}
+import org.scalatest.Tag
+
+/**
+ * Group memberships are recorded apart from the Entitlements a Group grants, so a Group whose Roles a
+ * user already held still counts them as a member; a Role two of the user's Groups grant is kept when
+ * one of them stops granting it; and sync-members brings members in line with a changed Group.
+ */
+class GroupMembershipSyncTest extends V600ServerSetup {
+
+ def v7 = baseRequest / "obp" / "v7.0.0"
+
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object SyncGroupMembers extends Tag("syncGroupMembers")
+ object AddEntitlement extends Tag("addEntitlement")
+
+ private def bankId = testBankId1.value
+ private def grant(role: ApiRole, bank: String = "") =
+ Entitlement.entitlement.vend.addEntitlement(bank, resourceUser1.userId, role.toString)
+ private def message(r: code.setup.APIResponse) = r.body.extract[ErrorMessage].message
+ private def sync(groupId: String) = v7 / "management" / "groups" / groupId / "sync-members"
+ private def membership(groupId: String) = compact(render("group_id" -> groupId))
+ private def user2Entitlements =
+ Entitlement.entitlement.vend.getEntitlementsByUserId(resourceUser2.userId).toList.flatten.filter(_.bankId == bankId)
+ private def roleGroup(role: String): Option[String] = user2Entitlements.find(_.roleName == role).flatMap(_.groupId)
+
+ // Any Role names do: a Group's Roles are stored as text and granted as Entitlements.
+ private val r1 = "CanGetCustomersAtOneBank"
+ private val r2 = "CanCreateCustomer"
+ private val r3 = "CanGetCustomer"
+
+ feature("Add Entitlement refuses a bank id that names no bank") {
+ scenario("a bank id that exists, differs only in case, SYS, or does not exist", AddEntitlement, VersionOfApi) {
+ grant(canCreateEntitlementAtAnyBank)
+ def add(bank: String) = makePostRequest((v7 / "users" / resourceUser2.userId / "entitlements").POST <@ (user1),
+ compact(render(("bank_id" -> bank) ~ ("role_name" -> canCreateCustomer.toString))))
+
+ Then("an unknown bank id is 404, and nothing is granted")
+ val unknown = add("no-such-bank-" + java.util.UUID.randomUUID().toString.take(8))
+ unknown.code should equal(404)
+ message(unknown) should startWith(BankNotFound)
+
+ Then("a bank id matching an existing one apart from case is 404 too")
+ if (bankId != bankId.toUpperCase) add(bankId.toUpperCase).code should equal(404)
+
+ Then("SYS, the system space, is accepted")
+ add(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID).code should equal(201)
+
+ Then("an existing bank id is accepted")
+ add(bankId).code should equal(201)
+ }
+ }
+
+ feature("Sync Group Members") {
+ scenario("unauthenticated, and without the Roles", SyncGroupMembers, VersionOfApi) {
+ val group = GroupTrait.group.vend.createGroup(Some(bankId), "sync-auth", "", List(r1), isEnabled = true).openOrThrowException("group")
+ val anonymous = makePostRequest(sync(group.groupId).POST, "")
+ anonymous.code should equal(401)
+ message(anonymous) should equal(AuthenticatedUserIsRequired)
+
+ grant(canAddUserToGroupAtOneBank, bankId)
+ val withAddOnly = makePostRequest(sync(group.groupId).POST <@ (user1), "")
+ withAddOnly.code should equal(403)
+ message(withAddOnly) should startWith(UserHasMissingRoles)
+ }
+
+ scenario("overlapping Groups: membership, sync, and removal", SyncGroupMembers, VersionOfApi) {
+ grant(canAddUserToGroupAtAllBanks)
+ grant(canRemoveUserFromGroupAtAllBanks)
+ grant(canGetUserGroupMembershipsAtAllBanks)
+ val a = GroupTrait.group.vend.createGroup(Some(bankId), "sync-A", "", List(r1, r2), isEnabled = true).openOrThrowException("A")
+ val b = GroupTrait.group.vend.createGroup(Some(bankId), "sync-B", "", List(r2), isEnabled = true).openOrThrowException("B")
+ val addUser2 = (v6_0_0_Request / "users" / resourceUser2.userId / "group-entitlements").POST <@ (user1)
+
+ When("user2 is added to A, then to B, whose only Role A already gave them")
+ makePostRequest(addUser2, membership(a.groupId)).code should equal(201)
+ makePostRequest(addUser2, membership(b.groupId)).code should equal(201)
+ roleGroup(r2) should equal(Some(a.groupId))
+
+ Then("user2 is still a member of B, although B granted them nothing")
+ GroupMemberships.groupIdsOfUser(resourceUser2.userId) should contain allOf (a.groupId, b.groupId)
+ val memberships = makeGetRequest((v6_0_0_Request / "users" / resourceUser2.userId / "group-entitlements").GET <@ (user1))
+ memberships.code should equal(200)
+ (memberships.body \ "group_entitlements").children.map(m => (m \ "group_id").extract[String]) should contain allOf (a.groupId, b.groupId)
+
+ When("A's Roles change to r1 and r3, and A is synced as a dry run")
+ GroupTrait.group.vend.updateGroup(a.groupId, None, None, Some(List(r1, r3)), None)
+ val dry = makePostRequest(sync(a.groupId).POST <@ (user1) < List(("dry_run", "true")), "")
+ dry.code should equal(200)
+ val dryMember = (dry.body \ "members").children.find(m => (m \ "user_id").extract[String] == resourceUser2.userId).get
+ (dryMember \ "entitlements_created").extract[List[String]] should equal(List(r3))
+ (dryMember \ "entitlements_deleted").extract[List[String]] shouldBe empty
+ (dryMember \ "entitlements_moved").children.map(m => (m \ "to_group_id").extract[String]) should equal(List(b.groupId))
+ Then("nothing changed")
+ user2Entitlements.map(_.roleName) should not contain r3
+ roleGroup(r2) should equal(Some(a.groupId))
+
+ When("A is synced for real")
+ makePostRequest(sync(a.groupId).POST <@ (user1), "").code should equal(200)
+ Then("user2 gains r3 from A, and keeps r2, now recorded against B")
+ roleGroup(r3) should equal(Some(a.groupId))
+ roleGroup(r2) should equal(Some(b.groupId))
+ roleGroup(r1) should equal(Some(a.groupId))
+
+ When("user2 is removed from B, and no other Group of theirs grants r2")
+ makeDeleteRequest((v6_0_0_Request / "users" / resourceUser2.userId / "group-entitlements" / b.groupId) <@ (user1))
+ .code should (equal(200) or equal(204))
+ Then("r2 is gone, and so is the membership")
+ roleGroup(r2) should equal(None)
+ GroupMemberships.groupIdsOfUser(resourceUser2.userId) should not contain b.groupId
+
+ When("A goes back to granting r2 and user2 is added to B again, then removed from A")
+ GroupTrait.group.vend.updateGroup(a.groupId, None, None, Some(List(r1, r2)), None)
+ makePostRequest(sync(a.groupId).POST <@ (user1), "").code should equal(200)
+ makePostRequest(addUser2, membership(b.groupId)).code should equal(201)
+ roleGroup(r2) should equal(Some(a.groupId))
+ makeDeleteRequest((v6_0_0_Request / "users" / resourceUser2.userId / "group-entitlements" / a.groupId) <@ (user1))
+ Then("r2 is kept, recorded against B; r1 and r3, which only A granted, are gone")
+ roleGroup(r2) should equal(Some(b.groupId))
+ roleGroup(r1) should equal(None)
+ roleGroup(r3) should equal(None)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala
new file mode 100644
index 0000000000..d3dbb8e060
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v7_0_0/PlatformAppsTest.scala
@@ -0,0 +1,144 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api.v7_0_0
+
+import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.{canCreatePlatformApp, canDeletePlatformApp, canGetDynamicEntityDefinitions, canGetPlatformApps}
+import code.api.util.ErrorMessages.{InvalidPlatformAppDeclaration, PlatformAppAlreadyExists, PlatformAppNotFound, UserHasMissingRoles}
+import code.api.v6_0_0.V600ServerSetup
+import code.entitlement.Entitlement
+import code.platformapp.PlatformApps
+import code.scope.Scope
+import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.util.ApiVersion
+import net.liftweb.common.Full
+import org.json4s.JsonDSL._
+import org.json4s.native.JsonMethods.{compact, render}
+import org.scalatest.Tag
+
+/** Platform Apps: an administrator marks a Consumer, the app declares the Scopes it needs as itself. */
+class PlatformAppsTest extends V600ServerSetup {
+
+ def v7 = baseRequest / "obp" / "v7.0.0"
+
+ object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString)
+ object ApiEndpoint1 extends Tag("createPlatformApp")
+ object ApiEndpoint2 extends Tag("getPlatformApps")
+ object ApiEndpoint3 extends Tag("deletePlatformApp")
+ object ApiEndpoint4 extends Tag("updateCurrentConsumerPlatformApp")
+
+ private def platformApps = v7 / "management" / "platform-apps"
+ private def declaration = v7 / "consumers" / "current" / "platform-app"
+ private def mark(consumerId: String, label: String) =
+ compact(render(("consumer_id" -> consumerId) ~ ("label" -> label)))
+ private def declare(scopes: (String, String, String, Boolean)*) =
+ compact(render(("version" -> "1.2.3") ~ ("required_scopes" -> scopes.toList.map { case (role, bank, neededFor, optional) =>
+ ("role_name" -> role) ~ ("bank_id" -> bank) ~ ("needed_for" -> neededFor) ~ ("optional" -> optional)
+ })))
+ private def grant(role: code.api.util.ApiRole) = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, role.toString)
+ private def message(r: code.setup.APIResponse) = r.body.extract[ErrorMessage].message
+
+ private val SYS = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID
+
+ feature("Platform Apps") {
+
+ scenario("mark a Consumer, let it declare its Scopes, see which it holds, and unmark it",
+ ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) {
+ val consumerId = testConsumer2.consumerId.get
+ PlatformApps.platformAppProvider.vend.deletePlatformApp(consumerId)
+ val entitlements = List(canCreatePlatformApp, canGetPlatformApps, canDeletePlatformApp)
+ var scope: net.liftweb.common.Box[Scope] = net.liftweb.common.Empty
+ try {
+ When("testConsumer2 declares before anyone has marked it")
+ val early = makePutRequest(declaration.PUT <@ (user2), declare((canGetDynamicEntityDefinitions.toString, SYS, "Finding entities.", false)))
+ Then("it is refused: only marked Consumers may declare")
+ early.code should equal(404)
+ message(early) should include(PlatformAppNotFound)
+
+ When("user1 marks it without the Role")
+ val refused = makePostRequest(platformApps.POST <@ (user1), mark(consumerId, "Portal"))
+ refused.code should equal(403)
+ message(refused) should include(UserHasMissingRoles)
+
+ val granted = entitlements.map(grant)
+ try {
+ When("user1 marks it with CanCreatePlatformApp")
+ val created = makePostRequest(platformApps.POST <@ (user1), mark(consumerId, "Portal"))
+ created.code should equal(201)
+ (created.body \ "state").extract[String] should equal("not_declared")
+
+ And("marking it again is refused")
+ makePostRequest(platformApps.POST <@ (user1), mark(consumerId, "Portal")).code should equal(409)
+
+ When("it declares a Role that does not exist")
+ val invalid = makePutRequest(declaration.PUT <@ (user2), declare(("CanDoNothingAtAll", SYS, "Nothing.", false)))
+ invalid.code should equal(400)
+ message(invalid) should include(InvalidPlatformAppDeclaration)
+
+ When("it declares one required and one optional Scope")
+ val declared = makePutRequest(declaration.PUT <@ (user2), declare(
+ (canGetDynamicEntityDefinitions.toString, SYS, "Finding entities.", false),
+ (canCreatePlatformApp.toString, "", "Nothing that matters.", true)))
+ Then("the required one is missing")
+ declared.code should equal(200)
+ (declared.body \ "state").extract[String] should equal("missing")
+ (declared.body \ "version").extract[String] should equal("1.2.3")
+
+ When("its Consumer is granted the required Scope")
+ scope = Scope.scope.vend.addScope(SYS, testConsumer2.id.get.toString, canGetDynamicEntityDefinitions.toString)
+ val listed = makeGetRequest(platformApps.GET <@ (user1))
+ Then("the list shows it held, and the app ok although the optional Scope is not held")
+ listed.code should equal(200)
+ val app = (listed.body \ "platform_apps").children.find(a => (a \ "consumer_id").extract[String] == consumerId)
+ .getOrElse(fail("the app should be listed"))
+ (app \ "state").extract[String] should equal("ok")
+ (app \ "label").extract[String] should equal("Portal")
+ val held = (app \ "required_scopes").children.map(s => ((s \ "role_name").extract[String], (s \ "held").extract[Boolean]))
+ held should contain((canGetDynamicEntityDefinitions.toString, true))
+ held should contain((canCreatePlatformApp.toString, false))
+
+ When("it is unmarked")
+ makeDeleteRequest((platformApps / consumerId).DELETE <@ (user1)).code should equal(204)
+ Then("it can no longer declare")
+ makePutRequest(declaration.PUT <@ (user2), declare((canGetDynamicEntityDefinitions.toString, SYS, "Finding entities.", false)))
+ .code should equal(404)
+ } finally granted.foreach(e => Entitlement.entitlement.vend.deleteEntitlement(e))
+ } finally {
+ scope.foreach(s => Scope.scope.vend.deleteScope(Full(s)))
+ PlatformApps.platformAppProvider.vend.deletePlatformApp(consumerId)
+ }
+ }
+
+ scenario("marking an unknown Consumer is a 404", ApiEndpoint1, VersionOfApi) {
+ val granted = grant(canCreatePlatformApp)
+ try {
+ makePostRequest(platformApps.POST <@ (user1), mark("no-such-consumer", "Nothing")).code should equal(404)
+ } finally Entitlement.entitlement.vend.deleteEntitlement(granted)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/telemetry/TrafficSourcesTest.scala b/obp-api/src/test/scala/code/telemetry/TrafficSourcesTest.scala
index a48cad20a9..dace8baa18 100644
--- a/obp-api/src/test/scala/code/telemetry/TrafficSourcesTest.scala
+++ b/obp-api/src/test/scala/code/telemetry/TrafficSourcesTest.scala
@@ -14,6 +14,9 @@ class TrafficSourcesTest extends FlatSpec with Matchers with BeforeAndAfterEach
private val minute = 60000L
private val t0 = 1790000000000L - 1790000000000L % minute // the start of a minute
+ /** A request that came straight from `ip`, with no forwarding header. */
+ private def direct(ip: String) = code.api.util.RemoteIpUtil.Resolution(ip, ip, forwardingHeaderPresent = false, headerHonoured = false, headerFromUntrustedPeer = false)
+
private def note(operationId: Option[String] = None, consumer: Option[String] = None, refusedBy: Option[String] = None): Note = {
val n = new Note
n.operationId = operationId
@@ -25,7 +28,7 @@ class TrafficSourcesTest extends FlatSpec with Matchers with BeforeAndAfterEach
}
"TrafficSources" should "count an authenticated request under its Consumer and its address, and pair the Consumer with the endpoint" in {
- TrafficSources.record(note(Some("OBPv7.0.0-getBanks"), Some("consumer-1")), "198.51.100.1", 200, 12, t0)
+ TrafficSources.record(note(Some("OBPv7.0.0-getBanks"), Some("consumer-1")), direct("198.51.100.1"), 200, 12, t0)
val consumers = TrafficSources.consumers(1, t0)
consumers.map(c => (c.key, c.requests)) shouldBe List(("consumer-1", 1L))
@@ -40,14 +43,14 @@ class TrafficSourcesTest extends FlatSpec with Matchers with BeforeAndAfterEach
}
it should "count an anonymous request under its address only, and group unknown paths as unmatched" in {
- TrafficSources.record(note(), "203.0.113.9", 404, 2, t0)
+ TrafficSources.record(note(), direct("203.0.113.9"), 404, 2, t0)
TrafficSources.consumers(1, t0) shouldBe empty
TrafficSources.addresses(1, t0).head.details.head.unmatched shouldBe 1L
TrafficSources.callerEndpoints(1, t0).map(_.key) shouldBe List((AddressCaller("203.0.113.9"), TrafficSources.UnmatchedEndpoint))
}
it should "record a refusal under the limiter that refused" in {
- TrafficSources.record(note(refusedBy = Some("ip_penalty")), "203.0.113.9", 429, 1, t0)
+ TrafficSources.record(note(refusedBy = Some("ip_penalty")), direct("203.0.113.9"), 429, 1, t0)
val pair = TrafficSources.callerEndpoints(1, t0).head
pair.key._2 shouldBe "refused:ip_penalty"
pair.details.head.refused shouldBe 1L
@@ -55,7 +58,7 @@ class TrafficSourcesTest extends FlatSpec with Matchers with BeforeAndAfterEach
it should "merge minutes into the window asked for, and leave out older minutes" in {
(0 until 3).foreach { m =>
- TrafficSources.record(note(Some("OBPv7.0.0-getBanks")), "203.0.113.9", 200, 5, t0 + m * minute)
+ TrafficSources.record(note(Some("OBPv7.0.0-getBanks")), direct("203.0.113.9"), 200, 5, t0 + m * minute)
}
val now = t0 + 2 * minute
TrafficSources.addresses(1, now).head.requests shouldBe 1L
diff --git a/release_notes.md b/release_notes.md
index e7004d9ef8..d91140152a 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -3,6 +3,66 @@
### Most recent changes at top of file
```
Date Commit Action
+29/09/2026 TBD NEW in v7.0.0: POST /management/groups/GROUP_ID/sync-members[?dry_run=true]
+ brings the Entitlements of a Group's members in line with its current Roles:
+ grants the Roles they lack, deletes those the Group granted but no longer
+ has, and keeps (recorded against that Group) any Role another of the
+ member's Groups still grants. Needs the add-to-group and remove-from-group
+ Roles at the Group's bank.
+29/09/2026 TBD CHANGED: Group memberships are recorded in a new table GroupMembership, so a
+ user added to a Group whose Roles they already held is still its member.
+ POST /users/USER_ID/group-entitlements writes the row; DELETE
+ /users/USER_ID/group-entitlements/GROUP_ID removes it and keeps (re-tagged)
+ any Entitlement another of the user's Groups also grants, instead of
+ deleting it; GET /users/USER_ID/group-entitlements also lists Groups that
+ granted the user nothing; DELETE /management/groups/GROUP_ID removes the
+ Group's rows. Members from before this change are still found through the
+ Entitlements their Groups granted. Responses are unchanged.
+29/09/2026 TBD CHANGED in v7.0.0: POST /users/USER_ID/entitlements returns 404 BankNotFound
+ when bank_id is neither empty, SYS, nor an existing Bank's id (matched
+ exactly, case included). It used to store a grant no Role check would read.
+28/09/2026 TBD NEW in v7.0.0: Platform Apps, the Consumers an installation runs as part of
+ its own deployment (Portal, API Manager, ...). POST, GET
+ /management/platform-apps and DELETE /management/platform-apps/CONSUMER_ID
+ (new Roles CanCreatePlatformApp, CanGetPlatformApps, CanDeletePlatformApp)
+ mark, list and unmark them; the list shows each app's declared Scopes, held
+ or not. PUT /consumers/current/platform-app (no Role, an Application on its
+ own may call it) lets a marked app declare the Scopes it needs and what for.
+ New tables platform_app and platform_app_required_scope. New error codes
+ OBP-35045 to OBP-35048. New Glossary item "Platform Apps".
+28/09/2026 TBD NEW in v7.0.0: POST /consumers/CONSUMER_ID/scopes. As v4.0.0's, but bank_id may
+ be SYS, the system space of Dynamic Entities (v4.0.0 refuses it with
+ BankNotFound), so the Definition Roles can be granted to a Consumer as
+ Scopes through the API. The granting Role (CanCreateScopeAtAnyBank, or
+ CanCreateScopeAtOneBank at bank_id) is checked at the body's bank_id.
+28/09/2026 TBD NEW in v7.0.0: GET /consumers/current/scopes, the Roles the calling Consumer
+ holds as Scopes (role_name, bank_id). No Role; a User or an Application on
+ its own may call it, like GET /consumers/current/identity. For services
+ (Portal, API Manager) to report on their status pages which Scopes they lack.
+28/09/2026 TBD CHANGED: CanCreateConsumer is added to the Roles of an OIDC operator: a
+ virtual Entitlement for users in oidc_operator_user_ids, and granted to the
+ bootstrap user created from oidc_operator_username (only when that user is
+ first created; an existing user keeps its stored Entitlements). OBP-OIDC
+ checks for it at startup when dynamic client registration or client
+ bootstrap is on.
+ CanGetConsumers is also added to the virtual Entitlements of users in
+ oidc_operator_user_ids (the bootstrap user already had it), so that list
+ alone satisfies OBP-OIDC's startup Role check.
+28/09/2026 TBD CHANGED in v7.0.0: GET and PUT /management/banks/BANK_ID/dynamic-entities
+ (list and update Dynamic Entity definitions) accept an Application on its
+ own (auth mode UserOrApplication), as POST already did, so a Consumer
+ holding CanGetDynamicEntityDefinitions / CanUpdateDynamicEntityDefinition
+ as a Scope can call them with a client-credentials token.
+28/09/2026 TBD NEW in v7.0.0: GET /management/system/diagnostics/deployment (Role
+ CanGetConfig), Deployment Checks: whether client addresses are passed on
+ and used, believed only from the proxy, spread across real clients;
+ whether applications pass on their users' addresses; rate-limit set-up;
+ root log level, Telemetry collection, Redis, API Metrics losses. Worked out
+ from props and the last 15 minutes of traffic.
+ NEW prop trust.proxy.peers: addresses or CIDR ranges whose forwarding header
+ is believed. Unset, behaviour is unchanged (believed from anyone).
+ FIXED: an IPv6 client address is now used without the brackets http4s puts
+ round it, in canonical form. IP penalties could not match IPv6 clients.
28/09/2026 TBD NEW in v7.0.0: GET /management/traffic/top-callers?window=1|5|15, where the
traffic on the answering instance is coming from: the busiest Consumers,
client IP addresses (every request, authenticated or not), and callers and
diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json
index f4a8981973..6f48df4ba2 100644
--- a/scripts/resource_doc_baseline/parity_allowlist.json
+++ b/scripts/resource_doc_baseline/parity_allowlist.json
@@ -1103,9 +1103,9 @@
"version": "v6_0_0",
"endpoint": "getUserGroupMemberships",
"field": "description",
- "reason": "Deliberate, reasoned simplification (documented in a matching code comment): filtering by group_id alone is functionally equivalent to the old group_id + process==\"GROUP_MEMBERSHIP\" conjunct, since group_id is only ever set for that process.",
+ "reason": "Deliberate, reasoned simplification (documented in a matching code comment): filtering by group_id alone is functionally equivalent to the old group_id + process==\"GROUP_MEMBERSHIP\" conjunct, since group_id is only ever set for that process. Group memberships are recorded in the GroupMembership table (2026-09-29): the description says the membership is recorded even when every Role is skipped, and that removal keeps a Role another of the user's Groups still grants.",
"lift_digest": "00695d674cb901c588ec78f9a5c33e065363fa0a5ea55ae23bde960ac464aec4",
- "http4s_digest": "cd76da70acd6a5991f43506b12bbdd5c4c07416e092afe8ff293c0d1ed1a889c"
+ "http4s_digest": "54312a80eabf953dcfcb0b4c1b6e0135a4d432506dcd7e09bba188e755bc3571"
},
{
"version": "v6_0_0",
@@ -1143,9 +1143,9 @@
"version": "v6_0_0",
"endpoint": "removeUserFromGroup",
"field": "description",
- "reason": "Same deliberate group_id-only simplification as getUserGroupMemberships, verified via the same matching code comment.",
+ "reason": "Same deliberate group_id-only simplification as getUserGroupMemberships, verified via the same matching code comment. Group memberships are recorded in the GroupMembership table (2026-09-29): the description says the membership is recorded even when every Role is skipped, and that removal keeps a Role another of the user's Groups still grants.",
"lift_digest": "7ed803789f6d7fb0ee0622523f4e3835ab7cbc166efd9c2454882836224db13f",
- "http4s_digest": "9e2a740a07e70eb3bdd0b54afe2406fa70aab3a7941fa88d02970657d5f8ee21"
+ "http4s_digest": "d73d70b3a441279c03890e68bcc7cd754c8a9ed4419d1e43b85e01bbee52a3ae"
},
{
"version": "v6_0_0",
@@ -1690,6 +1690,14 @@
"reason": "The props named in this description were renamed: dynamic_code_compile_validate_enable is now dynamic_code_obp_calls_are_restricted, and dynamic_code_compile_validate_dependencies is now dynamic_code_allowed_obp_methods. The old names read as a compile check, which is not what they do; both old names are still honoured at runtime with a deprecation warning.",
"lift_digest": "047ea29aa10e0000c66a13b908ff679defb15226fd931f00b723cacd66a12928",
"http4s_digest": "f326a54473e63b08e1b47763d2bdbd7b5574bc4906f067072898ec32c58c6954"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "addUserToGroup",
+ "field": "description",
+ "reason": "Group memberships are recorded in the GroupMembership table (2026-09-29): the description says the membership is recorded even when every Role is skipped, and that removal keeps a Role another of the user's Groups still grants.",
+ "lift_digest": "2ea4d8ed48487fd253cf1633a68deb7a0ce5b2a6b554ecfb64ee798f4887297c",
+ "http4s_digest": "2766a28acb728f97343d2de181f5ddbe89ff62dfdf61d191453a1ce4b6cf602f"
}
]
}