From a366afaad4ab5de85ac06bdb6059ccaefe94ee2d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 03:22:28 +0100 Subject: [PATCH 01/37] Tests for cache info etc --- .../code/api/v6_0_0/CacheEndpointsTest.scala | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala new file mode 100644 index 000000000..8b40957db --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -0,0 +1,353 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2024, 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 +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.{CanGetCacheConfig, CanGetCacheInfo, CanInvalidateCacheNamespace} +import code.api.util.ErrorMessages.{InvalidJsonFormat, UserHasMissingRoles, UserNotLoggedIn} +import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 +import code.entitlement.Entitlement +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + +class CacheEndpointsTest extends V600ServerSetup { + /** + * Test tags + * Example: To run tests with tag "getCacheConfig": + * mvn test -D tagsToInclude + * + * This is made possible by the scalatest maven plugin + */ + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.getCacheConfig)) + object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.getCacheInfo)) + object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.invalidateCacheNamespace)) + + // ============================================================================================================ + // GET /system/cache/config - Get Cache Configuration + // ============================================================================================================ + + feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") { + scenario("We call getCacheConfig without user credentials", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0 without credentials") + val request = (v6_0_0_Request / "system" / "cache" / "config").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + } + + feature(s"test $ApiEndpoint1 version $VersionOfApi - Missing role") { + scenario("We call getCacheConfig without the CanGetCacheConfig role", ApiEndpoint1, VersionOfApi) { + When("We make a request v6.0.0 without the required role") + val request = (v6_0_0_Request / "system" / "cache" / "config").GET <@ (user1) + val response = makeGetRequest(request) + Then("We should get a 403") + response.code should equal(403) + And("error should be " + UserHasMissingRoles + CanGetCacheConfig) + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetCacheConfig) + } + } + + feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access") { + scenario("We call getCacheConfig with the CanGetCacheConfig role", ApiEndpoint1, VersionOfApi) { + Given("We have a user with CanGetCacheConfig entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCacheConfig.toString) + + When("We make a request v6.0.0 with proper role") + val request = (v6_0_0_Request / "system" / "cache" / "config").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The response should have the correct structure") + val cacheConfig = response.body.extract[CacheConfigJsonV600] + cacheConfig.providers should not be empty + cacheConfig.instance_id should not be empty + cacheConfig.environment should not be empty + cacheConfig.global_prefix should not be empty + + And("Providers should have valid data") + cacheConfig.providers.foreach { provider => + provider.provider should not be empty + provider.enabled shouldBe a[Boolean] + } + } + } + + // ============================================================================================================ + // GET /system/cache/info - Get Cache Information + // ============================================================================================================ + + feature(s"test $ApiEndpoint2 version $VersionOfApi - Unauthorized access") { + scenario("We call getCacheInfo without user credentials", ApiEndpoint2, VersionOfApi) { + When("We make a request v6.0.0 without credentials") + val request = (v6_0_0_Request / "system" / "cache" / "info").GET + val response = makeGetRequest(request) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + } + + feature(s"test $ApiEndpoint2 version $VersionOfApi - Missing role") { + scenario("We call getCacheInfo without the CanGetCacheInfo role", ApiEndpoint2, VersionOfApi) { + When("We make a request v6.0.0 without the required role") + val request = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1) + val response = makeGetRequest(request) + Then("We should get a 403") + response.code should equal(403) + And("error should be " + UserHasMissingRoles + CanGetCacheInfo) + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetCacheInfo) + } + } + + feature(s"test $ApiEndpoint2 version $VersionOfApi - Authorized access") { + scenario("We call getCacheInfo with the CanGetCacheInfo role", ApiEndpoint2, VersionOfApi) { + Given("We have a user with CanGetCacheInfo entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCacheInfo.toString) + + When("We make a request v6.0.0 with proper role") + val request = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The response should have the correct structure") + val cacheInfo = response.body.extract[CacheInfoJsonV600] + cacheInfo.namespaces should not be null + cacheInfo.total_keys should be >= 0 + cacheInfo.redis_available shouldBe a[Boolean] + + And("Each namespace should have valid data") + cacheInfo.namespaces.foreach { namespace => + namespace.namespace_id should not be empty + namespace.prefix should not be empty + namespace.current_version should be > 0L + namespace.key_count should be >= 0 + namespace.description should not be empty + namespace.category should not be empty + } + } + } + + // ============================================================================================================ + // POST /management/cache/namespaces/invalidate - Invalidate Cache Namespace + // ============================================================================================================ + + feature(s"test $ApiEndpoint3 version $VersionOfApi - Unauthorized access") { + scenario("We call invalidateCacheNamespace without user credentials", ApiEndpoint3, VersionOfApi) { + When("We make a request v6.0.0 without credentials") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST + val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("rd_localised"))) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(UserNotLoggedIn) + } + } + + feature(s"test $ApiEndpoint3 version $VersionOfApi - Missing role") { + scenario("We call invalidateCacheNamespace without the CanInvalidateCacheNamespace role", ApiEndpoint3, VersionOfApi) { + When("We make a request v6.0.0 without the required role") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("rd_localised"))) + Then("We should get a 403") + response.code should equal(403) + And("error should be " + UserHasMissingRoles + CanInvalidateCacheNamespace) + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanInvalidateCacheNamespace) + } + } + + feature(s"test $ApiEndpoint3 version $VersionOfApi - Invalid JSON format") { + scenario("We call invalidateCacheNamespace with invalid JSON", ApiEndpoint3, VersionOfApi) { + Given("We have a user with CanInvalidateCacheNamespace entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We make a request with invalid JSON") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response = makePostRequest(request, """{"invalid": "json"}""") + + Then("We should get a 400") + response.code should equal(400) + And("error should be InvalidJsonFormat") + response.body.extract[ErrorMessage].message should startWith(InvalidJsonFormat) + } + } + + feature(s"test $ApiEndpoint3 version $VersionOfApi - Invalid namespace_id") { + scenario("We call invalidateCacheNamespace with non-existent namespace_id", ApiEndpoint3, VersionOfApi) { + Given("We have a user with CanInvalidateCacheNamespace entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We make a request with invalid namespace_id") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("invalid_namespace"))) + + Then("We should get a 400") + response.code should equal(400) + And("error should mention invalid namespace_id") + val errorMessage = response.body.extract[ErrorMessage].message + errorMessage should include("Invalid namespace_id") + errorMessage should include("invalid_namespace") + } + } + + feature(s"test $ApiEndpoint3 version $VersionOfApi - Authorized access with valid namespace") { + scenario("We call invalidateCacheNamespace with valid rd_localised namespace", ApiEndpoint3, VersionOfApi) { + Given("We have a user with CanInvalidateCacheNamespace entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We make a request with valid namespace_id") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("rd_localised"))) + + Then("We should get a 200") + response.code should equal(200) + + And("The response should have the correct structure") + val result = response.body.extract[InvalidatedCacheNamespaceJsonV600] + result.namespace_id should equal("rd_localised") + result.old_version should be > 0L + result.new_version should be > result.old_version + result.new_version should equal(result.old_version + 1) + result.status should equal("invalidated") + } + + scenario("We call invalidateCacheNamespace with valid connector namespace", ApiEndpoint3, VersionOfApi) { + Given("We have a user with CanInvalidateCacheNamespace entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We make a request with connector namespace_id") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("connector"))) + + Then("We should get a 200") + response.code should equal(200) + + And("The response should have the correct structure") + val result = response.body.extract[InvalidatedCacheNamespaceJsonV600] + result.namespace_id should equal("connector") + result.old_version should be > 0L + result.new_version should be > result.old_version + result.status should equal("invalidated") + } + + scenario("We call invalidateCacheNamespace with valid abac_rule namespace", ApiEndpoint3, VersionOfApi) { + Given("We have a user with CanInvalidateCacheNamespace entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We make a request with abac_rule namespace_id") + val request = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response = makePostRequest(request, write(InvalidateCacheNamespaceJsonV600("abac_rule"))) + + Then("We should get a 200") + response.code should equal(200) + + And("The response should have the correct structure") + val result = response.body.extract[InvalidatedCacheNamespaceJsonV600] + result.namespace_id should equal("abac_rule") + result.status should equal("invalidated") + } + } + + feature(s"test $ApiEndpoint3 version $VersionOfApi - Version increment validation") { + scenario("We verify that cache version increments correctly on multiple invalidations", ApiEndpoint3, VersionOfApi) { + Given("We have a user with CanInvalidateCacheNamespace entitlement") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We invalidate the same namespace twice") + val request1 = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response1 = makePostRequest(request1, write(InvalidateCacheNamespaceJsonV600("rd_dynamic"))) + + Then("First invalidation should succeed") + response1.code should equal(200) + val result1 = response1.body.extract[InvalidatedCacheNamespaceJsonV600] + val firstNewVersion = result1.new_version + + When("We invalidate again") + val request2 = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val response2 = makePostRequest(request2, write(InvalidateCacheNamespaceJsonV600("rd_dynamic"))) + + Then("Second invalidation should succeed") + response2.code should equal(200) + val result2 = response2.body.extract[InvalidatedCacheNamespaceJsonV600] + + And("Version should have incremented again") + result2.old_version should equal(firstNewVersion) + result2.new_version should equal(firstNewVersion + 1) + result2.status should equal("invalidated") + } + } + + // ============================================================================================================ + // Cross-endpoint test - Verify cache info updates after invalidation + // ============================================================================================================ + + feature(s"Integration test - Cache endpoints interaction") { + scenario("We verify cache info shows updated version after invalidation", ApiEndpoint2, ApiEndpoint3, VersionOfApi) { + Given("We have a user with both CanGetCacheInfo and CanInvalidateCacheNamespace entitlements") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetCacheInfo.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanInvalidateCacheNamespace.toString) + + When("We get the initial cache info") + val getRequest1 = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1) + val getResponse1 = makeGetRequest(getRequest1) + getResponse1.code should equal(200) + val cacheInfo1 = getResponse1.body.extract[CacheInfoJsonV600] + + // Find the rd_static namespace (or any other valid namespace) + val targetNamespace = "rd_static" + val initialVersion = cacheInfo1.namespaces.find(_.namespace_id == targetNamespace).map(_.current_version) + + When("We invalidate the namespace") + val invalidateRequest = (v6_0_0_Request / "management" / "cache" / "namespaces" / "invalidate").POST <@ (user1) + val invalidateResponse = makePostRequest(invalidateRequest, write(InvalidateCacheNamespaceJsonV600(targetNamespace))) + invalidateResponse.code should equal(200) + val invalidateResult = invalidateResponse.body.extract[InvalidatedCacheNamespaceJsonV600] + + When("We get the cache info again") + val getRequest2 = (v6_0_0_Request / "system" / "cache" / "info").GET <@ (user1) + val getResponse2 = makeGetRequest(getRequest2) + getResponse2.code should equal(200) + val cacheInfo2 = getResponse2.body.extract[CacheInfoJsonV600] + + Then("The namespace version should have been incremented") + val updatedNamespace = cacheInfo2.namespaces.find(_.namespace_id == targetNamespace) + updatedNamespace should not be None + + if (initialVersion.isDefined) { + updatedNamespace.get.current_version should be > initialVersion.get + } + updatedNamespace.get.current_version should equal(invalidateResult.new_version) + } + } +} From 5e00e012dbead3034e3fc89f4f75bf5085cd1472 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 03:40:41 +0100 Subject: [PATCH 02/37] Cache info storage_location --- .../main/scala/code/api/cache/InMemory.scala | 18 ++++++++ .../scala/code/api/v6_0_0/APIMethods600.scala | 11 ++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 45 +++++++++++++++---- .../code/api/v6_0_0/CacheEndpointsTest.scala | 2 + 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/code/api/cache/InMemory.scala b/obp-api/src/main/scala/code/api/cache/InMemory.scala index ba86bbfa6..9c4054430 100644 --- a/obp-api/src/main/scala/code/api/cache/InMemory.scala +++ b/obp-api/src/main/scala/code/api/cache/InMemory.scala @@ -25,4 +25,22 @@ object InMemory extends MdcLoggable { logger.trace(s"InMemory.memoizeWithInMemory.underlyingGuavaCache size ${underlyingGuavaCache.size()}, current cache key is $cacheKey") memoize(ttl)(f) } + + /** + * Count keys matching a pattern in the in-memory cache + * @param pattern Pattern to match (supports * wildcard) + * @return Number of matching keys + */ + def countKeys(pattern: String): Int = { + try { + val regex = pattern.replace("*", ".*").r + val allKeys = underlyingGuavaCache.asMap().keySet() + import scala.collection.JavaConverters._ + allKeys.asScala.count(key => regex.pattern.matcher(key).matches()) + } catch { + case e: Throwable => + logger.error(s"Error counting in-memory cache keys for pattern $pattern: ${e.getMessage}") + 0 + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 70a3f3565..02c824ce0 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -733,6 +733,11 @@ trait APIMethods600 { |- Current version counter |- Number of keys in each namespace |- Description and category + |- Storage location (redis, memory, both, or unknown) + | - "redis": Keys stored in Redis + | - "memory": Keys stored in in-memory cache + | - "both": Keys in both locations (indicates a BUG - should never happen) + | - "unknown": No keys found, storage location cannot be determined |- Total key count across all namespaces |- Redis availability status | @@ -749,7 +754,8 @@ trait APIMethods600 { current_version = 1, key_count = 42, description = "Rate limit call counters", - category = "Rate Limiting" + category = "Rate Limiting", + storage_location = "redis" ), CacheNamespaceInfoJsonV600( namespace_id = "rd_localised", @@ -757,7 +763,8 @@ trait APIMethods600 { current_version = 1, key_count = 128, description = "Localized resource docs", - category = "API Documentation" + category = "API Documentation", + storage_location = "redis" ) ), total_keys = 170, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index ae8587f8b..2a29d7a96 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -289,7 +289,8 @@ case class CacheNamespaceInfoJsonV600( current_version: Long, key_count: Int, description: String, - category: String + category: String, + storage_location: String ) case class CacheInfoJsonV600( @@ -1153,7 +1154,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { } def createCacheInfoJsonV600(): CacheInfoJsonV600 = { - import code.api.cache.Redis + import code.api.cache.{Redis, InMemory} import code.api.Constant val namespaceDescriptions = Map( @@ -1178,14 +1179,41 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { val prefix = Constant.getVersionedCachePrefix(namespaceId) val pattern = s"${prefix}*" - val keyCount = try { - val count = Redis.countKeys(pattern) - totalKeys += count - count + // Dynamically determine storage location by checking where keys exist + var redisKeyCount = 0 + var memoryKeyCount = 0 + var storageLocation = "unknown" + + try { + redisKeyCount = Redis.countKeys(pattern) + totalKeys += redisKeyCount } catch { case _: Throwable => redisAvailable = false - 0 + } + + try { + memoryKeyCount = InMemory.countKeys(pattern) + totalKeys += memoryKeyCount + } catch { + case _: Throwable => + // In-memory cache error (shouldn't happen, but handle gracefully) + } + + // Determine storage based on where keys actually exist + val keyCount = if (redisKeyCount > 0 && memoryKeyCount > 0) { + storageLocation = "both" + redisKeyCount + memoryKeyCount + } else if (redisKeyCount > 0) { + storageLocation = "redis" + redisKeyCount + } else if (memoryKeyCount > 0) { + storageLocation = "memory" + memoryKeyCount + } else { + // No keys found in either location - we don't know where they would be stored + storageLocation = "unknown" + 0 } val (description, category) = namespaceDescriptions.getOrElse(namespaceId, ("Unknown namespace", "Other")) @@ -1196,7 +1224,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { current_version = version, key_count = keyCount, description = description, - category = category + category = category, + storage_location = storageLocation ) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala index 8b40957db..ee8460f73 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -156,6 +156,8 @@ class CacheEndpointsTest extends V600ServerSetup { namespace.key_count should be >= 0 namespace.description should not be empty namespace.category should not be empty + namespace.storage_location should not be empty + namespace.storage_location should (equal("redis") or equal("memory") or equal("both") or equal("unknown")) } } } From f365523360aa4bd159df7ddd20dea063d447739f Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 04:34:55 +0100 Subject: [PATCH 03/37] System Cache Config fields --- .../scala/code/api/v6_0_0/APIMethods600.scala | 37 ++++---- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 93 ++++++++++++++----- .../code/api/v6_0_0/CacheEndpointsTest.scala | 2 + 3 files changed, 89 insertions(+), 43 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 02c824ce0..69190fb78 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -667,8 +667,8 @@ trait APIMethods600 { "Get Cache Configuration", """Returns cache configuration information including: | - |- Available cache providers (Redis, In-Memory) - |- Redis connection details (URL, port, SSL) + |- Redis status: availability, connection details (URL, port, SSL) + |- In-memory cache status: availability and current size |- Instance ID and environment |- Global cache namespace prefix | @@ -678,21 +678,15 @@ trait APIMethods600 { |""", EmptyBody, CacheConfigJsonV600( - providers = List( - CacheProviderConfigJsonV600( - provider = "redis", - enabled = true, - url = Some("127.0.0.1"), - port = Some(6379), - use_ssl = Some(false) - ), - CacheProviderConfigJsonV600( - provider = "in_memory", - enabled = true, - url = None, - port = None, - use_ssl = None - ) + redis_status = RedisCacheStatusJsonV600( + available = true, + url = "127.0.0.1", + port = 6379, + use_ssl = false + ), + in_memory_status = InMemoryCacheStatusJsonV600( + available = true, + current_size = 42 ), instance_id = "obp", environment = "dev", @@ -738,6 +732,9 @@ trait APIMethods600 { | - "memory": Keys stored in in-memory cache | - "both": Keys in both locations (indicates a BUG - should never happen) | - "unknown": No keys found, storage location cannot be determined + |- TTL info: Sampled TTL information from actual keys + | - Shows actual TTL values from up to 5 sample keys + | - Format: "123s" (fixed), "range 60s to 3600s (avg 1800s)" (variable), "no expiry" (persistent) |- Total key count across all namespaces |- Redis availability status | @@ -755,7 +752,8 @@ trait APIMethods600 { key_count = 42, description = "Rate limit call counters", category = "Rate Limiting", - storage_location = "redis" + storage_location = "redis", + ttl_info = "range 60s to 86400s (avg 3600s)" ), CacheNamespaceInfoJsonV600( namespace_id = "rd_localised", @@ -764,7 +762,8 @@ trait APIMethods600 { key_count = 128, description = "Localized resource docs", category = "API Documentation", - storage_location = "redis" + storage_location = "redis", + ttl_info = "3600s" ) ), total_keys = 170, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 2a29d7a96..36ab2d96b 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -268,16 +268,21 @@ case class InvalidatedCacheNamespaceJsonV600( status: String ) -case class CacheProviderConfigJsonV600( - provider: String, - enabled: Boolean, - url: Option[String], - port: Option[Int], - use_ssl: Option[Boolean] +case class RedisCacheStatusJsonV600( + available: Boolean, + url: String, + port: Int, + use_ssl: Boolean +) + +case class InMemoryCacheStatusJsonV600( + available: Boolean, + current_size: Long ) case class CacheConfigJsonV600( - providers: List[CacheProviderConfigJsonV600], + redis_status: RedisCacheStatusJsonV600, + in_memory_status: InMemoryCacheStatusJsonV600, instance_id: String, environment: String, global_prefix: String @@ -290,7 +295,8 @@ case class CacheNamespaceInfoJsonV600( key_count: Int, description: String, category: String, - storage_location: String + storage_location: String, + ttl_info: String ) case class CacheInfoJsonV600( @@ -1120,21 +1126,17 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { import code.api.Constant import net.liftweb.util.Props - val redisProvider = CacheProviderConfigJsonV600( - provider = "redis", - enabled = true, - url = Some(Redis.url), - port = Some(Redis.port), - use_ssl = Some(Redis.useSsl) - ) + val redisIsReady = try { + Redis.isRedisReady + } catch { + case _: Throwable => false + } - val inMemoryProvider = CacheProviderConfigJsonV600( - provider = "in_memory", - enabled = true, - url = None, - port = None, - use_ssl = None - ) + val inMemorySize = try { + InMemory.underlyingGuavaCache.size() + } catch { + case _: Throwable => 0L + } val instanceId = code.api.util.APIUtil.getPropsValue("api_instance_id").getOrElse("obp") val environment = Props.mode match { @@ -1145,8 +1147,21 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { case _ => "unknown" } + val redisStatus = RedisCacheStatusJsonV600( + available = redisIsReady, + url = Redis.url, + port = Redis.port, + use_ssl = Redis.useSsl + ) + + val inMemoryStatus = InMemoryCacheStatusJsonV600( + available = inMemorySize >= 0, + current_size = inMemorySize + ) + CacheConfigJsonV600( - providers = List(redisProvider, inMemoryProvider), + redis_status = redisStatus, + in_memory_status = inMemoryStatus, instance_id = instanceId, environment = environment, global_prefix = Constant.getGlobalCacheNamespacePrefix @@ -1156,6 +1171,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createCacheInfoJsonV600(): CacheInfoJsonV600 = { import code.api.cache.{Redis, InMemory} import code.api.Constant + import code.api.JedisMethod val namespaceDescriptions = Map( Constant.CALL_COUNTER_NAMESPACE -> ("Rate limit call counters", "Rate Limiting"), @@ -1183,10 +1199,33 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { var redisKeyCount = 0 var memoryKeyCount = 0 var storageLocation = "unknown" + var ttlInfo = "no keys to sample" try { redisKeyCount = Redis.countKeys(pattern) totalKeys += redisKeyCount + + // Sample keys to get TTL information + if (redisKeyCount > 0) { + val sampleKeys = Redis.scanKeys(pattern).take(5) + val ttls = sampleKeys.flatMap { key => + Redis.use(JedisMethod.TTL, key, None, None).map(_.toLong) + } + + if (ttls.nonEmpty) { + val minTtl = ttls.min + val maxTtl = ttls.max + val avgTtl = ttls.sum / ttls.length.toLong + + ttlInfo = if (minTtl == maxTtl) { + if (minTtl == -1) "no expiry" + else if (minTtl == -2) "keys expired or missing" + else s"${minTtl}s" + } else { + s"range ${minTtl}s to ${maxTtl}s (avg ${avgTtl}s)" + } + } + } } catch { case _: Throwable => redisAvailable = false @@ -1195,6 +1234,10 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { try { memoryKeyCount = InMemory.countKeys(pattern) totalKeys += memoryKeyCount + + if (memoryKeyCount > 0 && redisKeyCount == 0) { + ttlInfo = "in-memory (no TTL in Guava cache)" + } } catch { case _: Throwable => // In-memory cache error (shouldn't happen, but handle gracefully) @@ -1203,6 +1246,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { // Determine storage based on where keys actually exist val keyCount = if (redisKeyCount > 0 && memoryKeyCount > 0) { storageLocation = "both" + ttlInfo = s"redis: ${ttlInfo}, memory: in-memory cache" redisKeyCount + memoryKeyCount } else if (redisKeyCount > 0) { storageLocation = "redis" @@ -1225,7 +1269,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { key_count = keyCount, description = description, category = category, - storage_location = storageLocation + storage_location = storageLocation, + ttl_info = ttlInfo ) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala index ee8460f73..0b181a03f 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -158,6 +158,8 @@ class CacheEndpointsTest extends V600ServerSetup { namespace.category should not be empty namespace.storage_location should not be empty namespace.storage_location should (equal("redis") or equal("memory") or equal("both") or equal("unknown")) + namespace.ttl_info should not be empty + namespace.ttl_info shouldBe a[String] } } } From 63194b3ead2c4d2e57234e16a31014694c72111c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 04:36:28 +0100 Subject: [PATCH 04/37] System Cache Config fields fix --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 69190fb78..3f8a0d05e 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -27,7 +27,7 @@ import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500} import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson} -import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheProviderConfigJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, UpdateAbacRuleJsonV600} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics From 848dee52b86a625513eae212a3017c4b5b1e5a64 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 1 Jan 2026 04:40:39 +0100 Subject: [PATCH 05/37] System Cache Config fields fix tests --- .../code/api/v6_0_0/CacheEndpointsTest.scala | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala index 0b181a03f..7dd6022da 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CacheEndpointsTest.scala @@ -90,16 +90,19 @@ class CacheEndpointsTest extends V600ServerSetup { And("The response should have the correct structure") val cacheConfig = response.body.extract[CacheConfigJsonV600] - cacheConfig.providers should not be empty cacheConfig.instance_id should not be empty cacheConfig.environment should not be empty cacheConfig.global_prefix should not be empty - And("Providers should have valid data") - cacheConfig.providers.foreach { provider => - provider.provider should not be empty - provider.enabled shouldBe a[Boolean] - } + And("Redis status should have valid data") + cacheConfig.redis_status.available shouldBe a[Boolean] + cacheConfig.redis_status.url should not be empty + cacheConfig.redis_status.port should be > 0 + cacheConfig.redis_status.use_ssl shouldBe a[Boolean] + + And("In-memory status should have valid data") + cacheConfig.in_memory_status.available shouldBe a[Boolean] + cacheConfig.in_memory_status.current_size should be >= 0L } } From 57ea96d6bb3185f17ea72a82752d112d7774fc77 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 4 Jan 2026 20:23:19 +0100 Subject: [PATCH 06/37] bugfix: support multiple oauth2.jwk_set.url --- obp-api/src/main/scala/code/api/OAuth2.scala | 106 +++++++++++------- .../scala/code/api/util/ErrorMessages.scala | 2 +- 2 files changed, 68 insertions(+), 40 deletions(-) diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 00800f99f..9ba607c7b 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -228,6 +228,71 @@ object OAuth2Login extends RestHelper with MdcLoggable { def urlOfJwkSets: Box[String] = Constant.oauth2JwkSetUrl + /** + * Get all JWKS URLs from configuration. + * This is a helper method for trying multiple JWKS URLs when validating tokens. + * We need more than one JWKS URL if we have multiple OIDC providers configured etc. + * @return List of all configured JWKS URLs + */ + protected def getAllJwksUrls: List[String] = { + val url: List[String] = Constant.oauth2JwkSetUrl.toList + url.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty) + } + + /** + * Try to validate a JWT token with multiple JWKS URLs. + * This is a generic retry mechanism that works for both ID tokens and access tokens. + * + * @param token The JWT token to validate + * @param tokenType Description of token type for logging (e.g., "ID token", "access token") + * @param validateFunc Function that validates token against a JWKS URL + * @tparam T The type of claims returned (IDTokenClaimsSet or JWTClaimsSet) + * @return Boxed claims or failure + */ + protected def tryValidateWithAllJwksUrls[T]( + token: String, + tokenType: String, + validateFunc: (String, String) => Box[T] + ): Box[T] = { + logger.debug(s"tryValidateWithAllJwksUrls - attempting to validate $tokenType") + + // Extract issuer for better error reporting + val actualIssuer = JwtUtil.getIssuer(token).getOrElse("NO_ISSUER_CLAIM") + logger.debug(s"tryValidateWithAllJwksUrls - JWT issuer claim: '$actualIssuer'") + + // Get all JWKS URLs + val allJwksUrls = getAllJwksUrls + + if (allJwksUrls.isEmpty) { + logger.debug(s"tryValidateWithAllJwksUrls - No JWKS URLs configured") + return Failure(Oauth2ThereIsNoUrlOfJwkSet) + } + + logger.debug(s"tryValidateWithAllJwksUrls - Will try ${allJwksUrls.size} JWKS URL(s): $allJwksUrls") + + // Try each JWKS URL until one succeeds + val results = allJwksUrls.map { url => + logger.debug(s"tryValidateWithAllJwksUrls - Trying JWKS URL: '$url'") + val result = validateFunc(token, url) + result match { + case Full(_) => + logger.debug(s"tryValidateWithAllJwksUrls - SUCCESS with JWKS URL: '$url'") + case Failure(msg, _, _) => + logger.debug(s"tryValidateWithAllJwksUrls - FAILED with JWKS URL: '$url', reason: $msg") + case _ => + logger.debug(s"tryValidateWithAllJwksUrls - FAILED with JWKS URL: '$url'") + } + result + } + + // Return the first successful result, or the last failure + results.find(_.isDefined).getOrElse { + logger.debug(s"tryValidateWithAllJwksUrls - All ${allJwksUrls.size} JWKS URL(s) failed for issuer: '$actualIssuer'") + logger.debug(s"tryValidateWithAllJwksUrls - Tried URLs: $allJwksUrls") + results.lastOption.getOrElse(Failure(Oauth2ThereIsNoUrlOfJwkSet)) + } + } + def checkUrlOfJwkSets(identityProvider: String) = { val url: List[String] = Constant.oauth2JwkSetUrl.toList val jwksUris: List[String] = url.map(_.toLowerCase()).map(_.split(",").toList).flatten @@ -310,47 +375,10 @@ object OAuth2Login extends RestHelper with MdcLoggable { }.getOrElse(false) } def validateIdToken(idToken: String): Box[IDTokenClaimsSet] = { - logger.debug(s"validateIdToken - attempting to validate ID token") - - // Extract issuer for better error reporting - val actualIssuer = JwtUtil.getIssuer(idToken).getOrElse("NO_ISSUER_CLAIM") - logger.debug(s"validateIdToken - JWT issuer claim: '$actualIssuer'") - - urlOfJwkSets match { - case Full(url) => - logger.debug(s"validateIdToken - using JWKS URL: '$url'") - JwtUtil.validateIdToken(idToken, url) - case ParamFailure(a, b, c, apiFailure : APIFailure) => - logger.debug(s"validateIdToken - ParamFailure: $a, $b, $c, $apiFailure") - logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'") - ParamFailure(a, b, c, apiFailure : APIFailure) - case Failure(msg, t, c) => - logger.debug(s"validateIdToken - Failure getting JWKS URL: $msg") - logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'") - if (msg.contains("OBP-20208")) { - logger.debug("validateIdToken - OBP-20208 Error Details:") - logger.debug(s"validateIdToken - JWT issuer claim: '$actualIssuer'") - logger.debug(s"validateIdToken - oauth2.jwk_set.url value: '${Constant.oauth2JwkSetUrl}'") - logger.debug("validateIdToken - Check that the JWKS URL configuration matches the JWT issuer") - } - Failure(msg, t, c) - case _ => - logger.debug("validateIdToken - No JWKS URL available") - logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'") - Failure(Oauth2ThereIsNoUrlOfJwkSet) - } + tryValidateWithAllJwksUrls(idToken, "ID token", JwtUtil.validateIdToken) } def validateAccessToken(accessToken: String): Box[JWTClaimsSet] = { - urlOfJwkSets match { - case Full(url) => - JwtUtil.validateAccessToken(accessToken, url) - case ParamFailure(a, b, c, apiFailure : APIFailure) => - ParamFailure(a, b, c, apiFailure : APIFailure) - case Failure(msg, t, c) => - Failure(msg, t, c) - case _ => - Failure(Oauth2ThereIsNoUrlOfJwkSet) - } + tryValidateWithAllJwksUrls(accessToken, "access token", JwtUtil.validateAccessToken) } /** New Style Endpoints * This function creates user based on "iss" and "sub" fields 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 acaad26de..4c17086c1 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -269,7 +269,7 @@ object ErrorMessages { val Oauth2ThereIsNoUrlOfJwkSet = "OBP-20203: There is no an URL of OAuth 2.0 server's JWK set, published at a well-known URL." val Oauth2BadJWTException = "OBP-20204: Bad JWT error. " val Oauth2ParseException = "OBP-20205: Parse error. " - val Oauth2BadJOSEException = "OBP-20206: Bad JSON Object Signing and Encryption (JOSE) exception. The ID token is invalid or expired. " + val Oauth2BadJOSEException = "OBP-20206: Bad JSON Object Signing and Encryption (JOSE) exception. The ID token is invalid or expired. OBP-API Admin should check the oauth2.jwk_set.url list contains the jwks url of the provider." val Oauth2JOSEException = "OBP-20207: Bad JSON Object Signing and Encryption (JOSE) exception. An internal JOSE exception was encountered. " val Oauth2CannotMatchIssuerAndJwksUriException = "OBP-20208: Cannot match the issuer and JWKS URI at this server instance. " val Oauth2TokenHaveNoConsumer = "OBP-20209: The token have no linked consumer. " From 060d9beeeed9f8257940323b53d209f75fb6a370 Mon Sep 17 00:00:00 2001 From: tesobe-daniel Date: Mon, 5 Jan 2026 13:35:10 +0100 Subject: [PATCH 07/37] Ignore GitHub directory in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d990d9c46..b52072215 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.github/* *.class *.db .DS_Store From 69cc8c008a92ed13302025cf459e44fc8318383b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 5 Jan 2026 17:40:54 +0100 Subject: [PATCH 08/37] Resource doc yaml respects content parameter --- .../api/ResourceDocs1_4_0/ResourceDocs140.scala | 15 ++++++++++----- .../ResourceDocsAPIMethods.scala | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala index 3845c33ea..70f4b4cd5 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala @@ -208,11 +208,16 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md val resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, locale) resourceDocsJson.resource_docs case _ => - // Get all resource docs for the requested version - val allResourceDocs = ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(List.empty) - val filteredResourceDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(allResourceDocs, resourceDocTags, partialFunctions) - val resourceDocJson = JSONFactory1_4_0.createResourceDocsJson(filteredResourceDocs, isVersion4OrHigher, locale) - resourceDocJson.resource_docs + contentParam match { + case Some(DYNAMIC) => + ImplementationsResourceDocs.getResourceDocsObpDynamicCached(resourceDocTags, partialFunctions, locale, None, isVersion4OrHigher).head.resource_docs + case Some(STATIC) => { + ImplementationsResourceDocs.getStaticResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, isVersion4OrHigher).head.resource_docs + } + case _ => { + ImplementationsResourceDocs.getAllResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, contentParam, isVersion4OrHigher).head.resource_docs + } + } } val hostname = HostName diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index 9966ee298..fc6c5995e 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -225,7 +225,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth * @param contentParam if this is Some(`true`), only show dynamic endpoints, if Some(`false`), only show static. If it is None, we will show all. default is None * @return */ - private def getStaticResourceDocsObpCached( + def getStaticResourceDocsObpCached( requestedApiVersionString: String, resourceDocTags: Option[List[ResourceDocTag]], partialFunctionNames: Option[List[String]], @@ -245,7 +245,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth * @param contentParam if this is Some(`true`), only show dynamic endpoints, if Some(`false`), only show static. If it is None, we will show all. default is None * @return */ - private def getAllResourceDocsObpCached( + def getAllResourceDocsObpCached( requestedApiVersionString: String, resourceDocTags: Option[List[ResourceDocTag]], partialFunctionNames: Option[List[String]], @@ -288,7 +288,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth } - private def getResourceDocsObpDynamicCached( + def getResourceDocsObpDynamicCached( resourceDocTags: Option[List[ResourceDocTag]], partialFunctionNames: Option[List[String]], locale: Option[String], From b7b240c92229cac16c282e14314378a92872af4e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 5 Jan 2026 23:47:21 +0100 Subject: [PATCH 09/37] Log cache separate endpoints and different Role names --- .../scala/code/api/cache/RedisLogger.scala | 12 +- .../main/scala/code/api/util/ApiRole.scala | 36 +-- .../scala/code/api/v5_1_0/APIMethods510.scala | 213 +++++++++++++++--- .../api/v5_1_0/LogCacheEndpointTest.scala | 74 +++--- 4 files changed, 236 insertions(+), 99 deletions(-) diff --git a/obp-api/src/main/scala/code/api/cache/RedisLogger.scala b/obp-api/src/main/scala/code/api/cache/RedisLogger.scala index ee9b58c3a..02db0209c 100644 --- a/obp-api/src/main/scala/code/api/cache/RedisLogger.scala +++ b/obp-api/src/main/scala/code/api/cache/RedisLogger.scala @@ -73,12 +73,12 @@ object RedisLogger { /** Map a LogLevel to its required entitlements */ def requiredRoles(level: LogLevel): List[ApiRole] = level match { - case TRACE => List(canGetTraceLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks) - case DEBUG => List(canGetDebugLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks) - case INFO => List(canGetInfoLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks) - case WARNING => List(canGetWarningLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks) - case ERROR => List(canGetErrorLevelLogsAtAllBanks, canGetAllLevelLogsAtAllBanks) - case ALL => List(canGetAllLevelLogsAtAllBanks) + case TRACE => List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll) + case DEBUG => List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll) + case INFO => List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll) + case WARNING => List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll) + case ERROR => List(canGetSystemLogCacheError, canGetSystemLogCacheAll) + case ALL => List(canGetSystemLogCacheAll) } } 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 c025fe7c2..9c7a990be 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -107,35 +107,23 @@ object ApiRole extends MdcLoggable{ // TRACE - case class CanGetTraceLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetTraceLevelLogsAtOneBank = CanGetTraceLevelLogsAtOneBank() - case class CanGetTraceLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetTraceLevelLogsAtAllBanks = CanGetTraceLevelLogsAtAllBanks() + case class CanGetSystemLogCacheTrace(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemLogCacheTrace = CanGetSystemLogCacheTrace() // DEBUG - case class CanGetDebugLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetDebugLevelLogsAtOneBank = CanGetDebugLevelLogsAtOneBank() - case class CanGetDebugLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetDebugLevelLogsAtAllBanks = CanGetDebugLevelLogsAtAllBanks() + case class CanGetSystemLogCacheDebug(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemLogCacheDebug = CanGetSystemLogCacheDebug() // INFO - case class CanGetInfoLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetInfoLevelLogsAtOneBank = CanGetInfoLevelLogsAtOneBank() - case class CanGetInfoLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetInfoLevelLogsAtAllBanks = CanGetInfoLevelLogsAtAllBanks() + case class CanGetSystemLogCacheInfo(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemLogCacheInfo = CanGetSystemLogCacheInfo() // WARNING - case class CanGetWarningLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetWarningLevelLogsAtOneBank = CanGetWarningLevelLogsAtOneBank() - case class CanGetWarningLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetWarningLevelLogsAtAllBanks = CanGetWarningLevelLogsAtAllBanks() + case class CanGetSystemLogCacheWarning(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemLogCacheWarning = CanGetSystemLogCacheWarning() // ERROR - case class CanGetErrorLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetErrorLevelLogsAtOneBank = CanGetErrorLevelLogsAtOneBank() - case class CanGetErrorLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetErrorLevelLogsAtAllBanks = CanGetErrorLevelLogsAtAllBanks() + case class CanGetSystemLogCacheError(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemLogCacheError = CanGetSystemLogCacheError() // ALL - case class CanGetAllLevelLogsAtOneBank(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetAllLevelLogsAtOneBank = CanGetAllLevelLogsAtOneBank() - case class CanGetAllLevelLogsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetAllLevelLogsAtAllBanks = CanGetAllLevelLogsAtAllBanks() + case class CanGetSystemLogCacheAll(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemLogCacheAll = CanGetSystemLogCacheAll() case class CanUpdateAgentStatusAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateAgentStatusAtAnyBank = CanUpdateAgentStatusAtAnyBank() diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 7357f474f..8f8968e45 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -238,55 +238,204 @@ trait APIMethods510 { } } + // Helper function to avoid code duplication + private def getLogCacheHelper(level: RedisLogger.LogLevel.Value, cc: CallContext): Future[(RedisLogger.LogTail, Option[CallContext])] = { + implicit val ec = EndpointContext(Some(cc)) + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) + limit = obpQueryParams.collectFirst { case OBPLimit(value) => value } + offset = obpQueryParams.collectFirst { case OBPOffset(value) => value } + logs <- Future(RedisLogger.getLogTail(level, limit, offset)) + } yield { + (logs, HttpCode.`200`(callContext)) + } + } + staticResourceDocs += ResourceDoc( - logCacheEndpoint, + logCacheTraceEndpoint, implementedInApiVersion, - nameOf(logCacheEndpoint), + nameOf(logCacheTraceEndpoint), "GET", - "/system/log-cache/LOG_LEVEL", - "Get Log Cache", - """Returns information about: - | - |* Log Cache + "/system/log-cache/trace", + "Get Trace Level Log Cache", + """Returns TRACE level logs from the system log cache. | |This endpoint supports pagination via the following optional query parameters: |* limit - Maximum number of log entries to return |* offset - Number of log entries to skip (for pagination) | - |Example: GET /system/log-cache/INFO?limit=50&offset=100 + |Example: GET /system/log-cache/trace?limit=50&offset=100 """, EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), apiTagSystem :: apiTagApi :: Nil, - Some(List(canGetAllLevelLogsAtAllBanks))) + Some(List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll))) - lazy val logCacheEndpoint: OBPEndpoint = { - case "system" :: "log-cache" :: logLevel :: Nil JsonGet _ => + lazy val logCacheTraceEndpoint: OBPEndpoint = { + case "system" :: "log-cache" :: "trace" :: Nil JsonGet _ => cc => implicit val ec = EndpointContext(Some(cc)) for { - // Parse and validate log level - level <- NewStyle.function.tryons(ErrorMessages.invalidLogLevel, 400, cc.callContext) { - RedisLogger.LogLevel.valueOf(logLevel) - } - // Check entitlements using helper - _ <- NewStyle.function.handleEntitlementsAndScopes( - bankId = "", - userId = cc.userId, - roles = RedisLogger.LogLevel.requiredRoles(level), - callContext = cc.callContext - ) - httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) - (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) - // Extract limit and offset from query parameters - limit = obpQueryParams.collectFirst { case OBPLimit(value) => value } - offset = obpQueryParams.collectFirst { case OBPOffset(value) => value } - // Fetch logs with pagination - logs <- Future(RedisLogger.getLogTail(level, limit, offset)) - } yield { - (logs, HttpCode.`200`(cc.callContext)) - } + _ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll), cc.callContext) + result <- getLogCacheHelper(RedisLogger.LogLevel.TRACE, cc) + } yield result + } + + staticResourceDocs += ResourceDoc( + logCacheDebugEndpoint, + implementedInApiVersion, + nameOf(logCacheDebugEndpoint), + "GET", + "/system/log-cache/debug", + "Get Debug Level Log Cache", + """Returns DEBUG level logs from the system log cache. + | + |This endpoint supports pagination via the following optional query parameters: + |* limit - Maximum number of log entries to return + |* offset - Number of log entries to skip (for pagination) + | + |Example: GET /system/log-cache/debug?limit=50&offset=100 + """, + EmptyBody, + EmptyBody, + List($UserNotLoggedIn, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll))) + + lazy val logCacheDebugEndpoint: OBPEndpoint = { + case "system" :: "log-cache" :: "debug" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + _ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll), cc.callContext) + result <- getLogCacheHelper(RedisLogger.LogLevel.DEBUG, cc) + } yield result + } + + staticResourceDocs += ResourceDoc( + logCacheInfoEndpoint, + implementedInApiVersion, + nameOf(logCacheInfoEndpoint), + "GET", + "/system/log-cache/info", + "Get Info Level Log Cache", + """Returns INFO level logs from the system log cache. + | + |This endpoint supports pagination via the following optional query parameters: + |* limit - Maximum number of log entries to return + |* offset - Number of log entries to skip (for pagination) + | + |Example: GET /system/log-cache/info?limit=50&offset=100 + """, + EmptyBody, + EmptyBody, + List($UserNotLoggedIn, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll))) + + lazy val logCacheInfoEndpoint: OBPEndpoint = { + case "system" :: "log-cache" :: "info" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + _ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll), cc.callContext) + result <- getLogCacheHelper(RedisLogger.LogLevel.INFO, cc) + } yield result + } + + staticResourceDocs += ResourceDoc( + logCacheWarningEndpoint, + implementedInApiVersion, + nameOf(logCacheWarningEndpoint), + "GET", + "/system/log-cache/warning", + "Get Warning Level Log Cache", + """Returns WARNING level logs from the system log cache. + | + |This endpoint supports pagination via the following optional query parameters: + |* limit - Maximum number of log entries to return + |* offset - Number of log entries to skip (for pagination) + | + |Example: GET /system/log-cache/warning?limit=50&offset=100 + """, + EmptyBody, + EmptyBody, + List($UserNotLoggedIn, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll))) + + lazy val logCacheWarningEndpoint: OBPEndpoint = { + case "system" :: "log-cache" :: "warning" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + _ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll), cc.callContext) + result <- getLogCacheHelper(RedisLogger.LogLevel.WARNING, cc) + } yield result + } + + staticResourceDocs += ResourceDoc( + logCacheErrorEndpoint, + implementedInApiVersion, + nameOf(logCacheErrorEndpoint), + "GET", + "/system/log-cache/error", + "Get Error Level Log Cache", + """Returns ERROR level logs from the system log cache. + | + |This endpoint supports pagination via the following optional query parameters: + |* limit - Maximum number of log entries to return + |* offset - Number of log entries to skip (for pagination) + | + |Example: GET /system/log-cache/error?limit=50&offset=100 + """, + EmptyBody, + EmptyBody, + List($UserNotLoggedIn, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetSystemLogCacheError, canGetSystemLogCacheAll))) + + lazy val logCacheErrorEndpoint: OBPEndpoint = { + case "system" :: "log-cache" :: "error" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + _ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheError, canGetSystemLogCacheAll), cc.callContext) + result <- getLogCacheHelper(RedisLogger.LogLevel.ERROR, cc) + } yield result + } + + staticResourceDocs += ResourceDoc( + logCacheAllEndpoint, + implementedInApiVersion, + nameOf(logCacheAllEndpoint), + "GET", + "/system/log-cache/all", + "Get All Level Log Cache", + """Returns logs of all levels from the system log cache. + | + |This endpoint supports pagination via the following optional query parameters: + |* limit - Maximum number of log entries to return + |* offset - Number of log entries to skip (for pagination) + | + |Example: GET /system/log-cache/all?limit=50&offset=100 + """, + EmptyBody, + EmptyBody, + List($UserNotLoggedIn, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetSystemLogCacheAll))) + + lazy val logCacheAllEndpoint: OBPEndpoint = { + case "system" :: "log-cache" :: "all" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + _ <- NewStyle.function.handleEntitlementsAndScopes("", cc.userId, List(canGetSystemLogCacheAll), cc.callContext) + result <- getLogCacheHelper(RedisLogger.LogLevel.ALL, cc) + } yield result } diff --git a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala index 70f1a8e56..690464e06 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala @@ -1,7 +1,7 @@ package code.api.v5_1_0 import code.api.util.APIUtil.OAuth._ -import code.api.util.ApiRole.CanGetAllLevelLogsAtAllBanks +import code.api.util.ApiRole.CanGetSystemLogCacheAll import code.api.util.ErrorMessages.{UserHasMissingRoles, UserNotLoggedIn} import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 import code.entitlement.Entitlement @@ -21,12 +21,12 @@ class LogCacheEndpointTest extends V510ServerSetup { * This is made possible by the scalatest maven plugin */ object VersionOfApi extends Tag(ApiVersion.v5_1_0.toString) - object ApiEndpoint1 extends Tag(nameOf(Implementations5_1_0.logCacheEndpoint)) + object ApiEndpoint1 extends Tag(nameOf(Implementations5_1_0.logCacheInfoEndpoint)) feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") { scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) { When("We make a request v5.1.0") - val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET + val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET val response = makeGetRequest(request) Then("We should get a 401") response.code should equal(401) @@ -37,21 +37,21 @@ class LogCacheEndpointTest extends V510ServerSetup { feature(s"test $ApiEndpoint1 version $VersionOfApi - Missing entitlement") { scenario("We will call the endpoint with user credentials but without proper entitlement", ApiEndpoint1, VersionOfApi) { When("We make a request v5.1.0") - val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) + val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) val response = makeGetRequest(request) - Then("error should be " + UserHasMissingRoles + CanGetAllLevelLogsAtAllBanks) + Then("error should be " + UserHasMissingRoles + CanGetSystemLogCacheAll) response.code should equal(403) - response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanGetAllLevelLogsAtAllBanks) + response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanGetSystemLogCacheAll) } } feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access without pagination") { scenario("We get log cache without pagination parameters", ApiEndpoint1, VersionOfApi) { Given("We have a user with proper entitlement") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString) When("We make a request to get log cache") - val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) + val request = (v5_1_0_Request / "system" / "log-cache" / "info").GET <@(user1) val response = makeGetRequest(request) Then("We should get a successful response") @@ -66,10 +66,10 @@ class LogCacheEndpointTest extends V510ServerSetup { feature(s"test $ApiEndpoint1 version $VersionOfApi - Authorized access with limit parameter") { scenario("We get log cache with limit parameter only", ApiEndpoint1, VersionOfApi) { Given("We have a user with proper entitlement") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAllLevelLogsAtAllBanks.toString) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetSystemLogCacheAll.toString) When("We make a request with limit parameter") - val request = (v5_1_0_Request / "system" / "log-cache" / "INFO").GET <@(user1) < val request = (v5_1_0_Request / "system" / "log-cache" / logLevel).GET <@(user1) < Date: Mon, 5 Jan 2026 23:49:39 +0100 Subject: [PATCH 10/37] Add apiTagLogCache tag to log cache endpoints --- obp-api/src/main/scala/code/api/util/ApiTag.scala | 1 + .../main/scala/code/api/v5_1_0/APIMethods510.scala | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index 91b4f3eb9..bd4c41f01 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -91,6 +91,7 @@ object ApiTag { val apiTagDevOps = ResourceDocTag("DevOps") val apiTagSystem = ResourceDocTag("System") val apiTagCache = ResourceDocTag("Cache") + val apiTagLogCache = ResourceDocTag("Log-Cache") val apiTagApiCollection = ResourceDocTag("Api-Collection") diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 8f8968e45..e3f26b02f 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -270,7 +270,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheTrace, canGetSystemLogCacheAll))) lazy val logCacheTraceEndpoint: OBPEndpoint = { @@ -301,7 +301,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheDebug, canGetSystemLogCacheAll))) lazy val logCacheDebugEndpoint: OBPEndpoint = { @@ -332,7 +332,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheInfo, canGetSystemLogCacheAll))) lazy val logCacheInfoEndpoint: OBPEndpoint = { @@ -363,7 +363,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheWarning, canGetSystemLogCacheAll))) lazy val logCacheWarningEndpoint: OBPEndpoint = { @@ -394,7 +394,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheError, canGetSystemLogCacheAll))) lazy val logCacheErrorEndpoint: OBPEndpoint = { @@ -425,7 +425,7 @@ trait APIMethods510 { EmptyBody, EmptyBody, List($UserNotLoggedIn, UnknownError), - apiTagSystem :: apiTagApi :: Nil, + apiTagSystem :: apiTagApi :: apiTagLogCache :: Nil, Some(List(canGetSystemLogCacheAll))) lazy val logCacheAllEndpoint: OBPEndpoint = { From d4605b27d742211a2d32a2c19fea1ec40f097c00 Mon Sep 17 00:00:00 2001 From: karmaking Date: Wed, 7 Jan 2026 19:16:01 +0100 Subject: [PATCH 11/37] merge test error reporting to build action --- .../build_container_develop_branch.yml | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index 793a4d81e..a5e8a87dc 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -1,19 +1,15 @@ -name: Build and publish container develop +name: Build and publish container non develop -# read-write repo token -# access to secrets on: - workflow_dispatch: push: branches: - - develop + - '*' + - '!develop' env: - ## Sets environment variable DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} DOCKER_HUB_REPOSITORY: obp-api - jobs: build: runs-on: ubuntu-latest @@ -33,6 +29,9 @@ jobs: --health-retries 5 steps: - uses: actions/checkout@v4 + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" - name: Set up JDK 11 uses: actions/setup-java@v4 with: @@ -41,6 +40,7 @@ jobs: cache: maven - name: Build with Maven run: | + set -o pipefail cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props @@ -76,7 +76,44 @@ jobs: echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log + + - name: Report failing tests (if any) + if: always() + run: | + echo "Checking build log for failing tests via grep..." + if [ ! -f maven-build.log ]; then + echo "No maven-build.log found; skipping failure scan." + exit 0 + fi + if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then + echo "Failing tests detected above." + exit 1 + else + echo "No failing tests detected in maven-build.log." + fi + + - name: Upload Maven build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: maven-build-log + if-no-files-found: ignore + path: | + maven-build.log + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + if-no-files-found: ignore + path: | + obp-api/target/surefire-reports/** + obp-commons/target/surefire-reports/** + **/target/scalatest-reports/** + **/target/site/surefire-report.html + **/target/site/surefire-report/* - name: Save .war artifact run: | @@ -86,3 +123,5 @@ jobs: with: name: ${{ github.sha }} path: push/ + + From d32947ba062c803682a34a53b72ecb52f91e3ab5 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 17 Dec 2025 18:27:42 +0100 Subject: [PATCH 12/37] refactor/Introduce http4s routes for v7.0.0 and update API resource docs - Add `Http4sEndpoint` type alias and `http4sPartialFunction` in APIUtil for handling http4s routes - Refactor Http4s700 to define routes as standalone functions (e.g., `root`, `getBanks`) within Implementations7_0_0 object - Attach resource documentation to each route for better maintainability - Create a unified `allRoutes` combining v7.0.0 route handlers - Update imports and clean up unused references --- .../main/scala/code/api/util/APIUtil.scala | 6 +- .../scala/code/api/v7_0_0/Http4s700.scala | 130 +++++++++++++----- 2 files changed, 101 insertions(+), 35 deletions(-) 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 d6fb5dbb4..381b0c283 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -27,6 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.api.util import bootstrap.liftweb.CustomDBVendor +import cats.effect.IO import code.accountholders.AccountHolders import code.api.Constant._ import code.api.OAuthHandshake._ @@ -96,6 +97,7 @@ import net.liftweb.util.Helpers._ import net.liftweb.util._ import org.apache.commons.io.IOUtils import org.apache.commons.lang3.StringUtils +import org.http4s.HttpRoutes import java.io.InputStream import java.net.URLDecoder @@ -1636,7 +1638,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ isFeatured: Boolean = false, specialInstructions: Option[String] = None, var specifiedUrl: Option[String] = None, // A derived value: Contains the called version (added at run time). See the resource doc for resource doc! - createdByBankId: Option[String] = None //we need to filter the resource Doc by BankId + createdByBankId: Option[String] = None, //we need to filter the resource Doc by BankId + http4sPartialFunction: Http4sEndpoint = None // http4s endpoint handler ) { // this code block will be merged to constructor. { @@ -2789,6 +2792,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ type OBPEndpoint = PartialFunction[Req, CallContext => Box[JsonResponse]] type OBPReturnType[T] = Future[(T, Option[CallContext])] + type Http4sEndpoint = Option[HttpRoutes[IO]] def getAllowedEndpoints (endpoints : Iterable[OBPEndpoint], resourceDocs: ArrayBuffer[ResourceDoc]) : List[OBPEndpoint] = { 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 877b91b72..05d4fb414 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 @@ -2,18 +2,23 @@ package code.api.v7_0_0 import cats.data.{Kleisli, OptionT} import cats.effect._ -import cats.implicits._ -import code.api.util.{APIUtil, CustomJsonFormats} +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil.{EmptyBody, _} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.{CustomJsonFormats, NewStyle} import code.api.v4_0_0.JSONFactory400 -import code.bankconnectors.Connector -import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.json.Formats +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.json.{Extraction, Formats} import net.liftweb.json.JsonAST.prettyRender -import net.liftweb.json.Extraction import org.http4s._ import org.http4s.dsl.io._ import org.typelevel.vault.Key +import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future import scala.language.{higherKinds, implicitConversions} @@ -24,12 +29,13 @@ object Http4s700 { implicit val formats: Formats = CustomJsonFormats.formats implicit def convertAnyToJsonString(any: Any): String = prettyRender(Extraction.decompose(any)) - val apiVersion: ScannedApiVersion = ApiVersion.v7_0_0 - val apiVersionString: String = apiVersion.toString + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v7_0_0 + val versionStatus = ApiVersionStatus.STABLE.toString + val resourceDocs = ArrayBuffer[ResourceDoc]() case class CallContext(userId: String, requestId: String) - import cats.effect.unsafe.implicits.global - val callContextKey: Key[CallContext] = Key.newKey[IO, CallContext].unsafeRunSync() + val callContextKey: Key[CallContext] = + Key.newKey[IO, CallContext].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) object CallContextMiddleware { @@ -42,31 +48,87 @@ object Http4s700 { } } - val v700Services: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> Root / "obp" / `apiVersionString` / "root" => - import com.openbankproject.commons.ExecutionContext.Implicits.global - val callContext = req.attributes.lookup(callContextKey).get.asInstanceOf[CallContext] - Ok(IO.fromFuture(IO( - for { - _ <- Future() // Just start async call - } yield { - convertAnyToJsonString( - JSONFactory700.getApiInfoJSON(apiVersion, s"Hello, ${callContext.userId}! Your request ID is ${callContext.requestId}.") - ) - } - ))) + object Implementations7_0_0 { - case req @ GET -> Root / "obp" / `apiVersionString` / "banks" => - import com.openbankproject.commons.ExecutionContext.Implicits.global - Ok(IO.fromFuture(IO( - for { - (banks, callContext) <- code.api.util.NewStyle.function.getBanks(None) - } yield { - convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) - } - ))) + // Common prefix: /obp/v7.0.0 + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(root), + "GET", + "/root", + "Get API Info (root)", + s"""Returns information about: + | + |* API version + |* Hosted by information + |* Git Commit + |${userAuthenticationMessage(false)}""", + EmptyBody, + apiInfoJSON, + List(UnknownError, "no connector set"), + apiTagApi :: Nil, + http4sPartialFunction = Some(root) + ) + + // Route: GET /obp/v7.0.0/root + val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "root" => + val callContext = req.attributes.lookup(callContextKey).get.asInstanceOf[CallContext] + Ok(IO.fromFuture(IO( + for { + _ <- Future() // Just start async call + } yield { + convertAnyToJsonString( + JSONFactory700.getApiInfoJSON(implementedInApiVersion, s"Hello, ${callContext.userId}! Your request ID is ${callContext.requestId}.") + ) + } + ))) + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBanks), + "GET", + "/banks", + "Get Banks", + s"""Get banks on this API instance + |Returns a list of banks supported on this server: + | + |* ID used as parameter in URLs + |* Short and full name of bank + |* Logo URL + |* Website + |${userAuthenticationMessage(false)}""", + EmptyBody, + banksJSON, + List(UnknownError), + apiTagBank :: Nil, + http4sPartialFunction = Some(getBanks) + ) + + // Route: GET /obp/v7.0.0/banks + val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" => + import com.openbankproject.commons.ExecutionContext.Implicits.global + Ok(IO.fromFuture(IO( + for { + (banks, callContext) <- NewStyle.function.getBanks(None) + } yield { + convertAnyToJsonString(JSONFactory400.createBanksJson(banks)) + } + ))) + } + + // All routes combined + val allRoutes: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + root(req).orElse(getBanks(req)) + } } - val wrappedRoutesV700Services: HttpRoutes[IO] = CallContextMiddleware.withCallContext(v700Services) + val wrappedRoutesV700Services: HttpRoutes[IO] = CallContextMiddleware.withCallContext(Implementations7_0_0.allRoutes) } - From 7c9095f0aade4f47dbaf45f39f0af5954f280e7c Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 17 Dec 2025 19:19:19 +0100 Subject: [PATCH 13/37] feature/Get resource docs endpoint for v7.0.0 - Introduce `getResourceDocsObpV700` to handle resource docs retrieval for API version 7.0.0 - Add `getResourceDocsList` helper function for fetching version-specific resource docs - Update `allRoutes` to include the new endpoint - Modify imports to include necessary utilities and remove unused references --- .../scala/code/api/v7_0_0/Http4s700.scala | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) 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 05d4fb414..40fdbb5b2 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 @@ -3,17 +3,19 @@ package code.api.v7_0_0 import cats.data.{Kleisli, OptionT} import cats.effect._ import code.api.Constant._ +import code.api.ResourceDocs1_4_0.ResourceDocsAPIMethodsUtil import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ -import code.api.util.{CustomJsonFormats, NewStyle} +import code.api.util.{ApiVersionUtils, CustomJsonFormats, NewStyle, ScannedApis} +import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v4_0_0.JSONFactory400 import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} -import net.liftweb.json.{Extraction, Formats} import net.liftweb.json.JsonAST.prettyRender +import net.liftweb.json.{Extraction, Formats} import org.http4s._ import org.http4s.dsl.io._ import org.typelevel.vault.Key @@ -53,6 +55,14 @@ object Http4s700 { // Common prefix: /obp/v7.0.0 val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + private def getResourceDocsList(requestedApiVersion: ApiVersion): List[ResourceDoc] = { + requestedApiVersion match { + case version: ScannedApiVersion => + ScannedApis.versionMapScannedApis.get(version).map(_.allResourceDocs.toList).getOrElse(Nil) + case _ => Nil + } + } + resourceDocs += ResourceDoc( null, implementedInApiVersion, @@ -123,10 +133,30 @@ object Http4s700 { ))) } + val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => + import com.openbankproject.commons.ExecutionContext.Implicits.global + val logic = for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption + functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption + localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption + contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption + apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption + tags = tagsParam.map(_.map(ResourceDocTag(_))) + functions = functionsParam.map(_.toList) + requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) + resourceDocs = getResourceDocsList(requestedApiVersion) + filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) + resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) + } yield convertAnyToJsonString(resourceDocsJson) + Ok(IO.fromFuture(IO(logic))) + } + // All routes combined val allRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => - root(req).orElse(getBanks(req)) + root(req).orElse(getBanks(req)).orElse(getResourceDocsObpV700(req)) } } From ad7b6fe357edfbf36a47e489b393d97a2edf3969 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 17 Dec 2025 22:42:12 +0100 Subject: [PATCH 14/37] refactor(Http4sServer): Reorder service initialization and improve comments --- .../main/scala/bootstrap/http4s/Http4sServer.scala | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index 8a8b3366f..72b0574d2 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -11,17 +11,16 @@ import org.http4s.implicits._ import scala.language.higherKinds object Http4sServer extends IOApp { - val services: Kleisli[({type λ[β$0$] = OptionT[IO, β$0$]})#λ, Request[IO], Response[IO]] = - code.api.v7_0_0.Http4s700.wrappedRoutesV700Services - - val httpApp: Kleisli[IO, Request[IO], Response[IO]] = (services).orNotFound - - //Start OBP relevant objects, and settings + //Start OBP relevant objects and settings; this step MUST be executed first new bootstrap.http4s.Http4sBoot().boot val port = APIUtil.getPropsAsIntValue("http4s.port",8181) val host = APIUtil.getPropsValue("http4s.host","127.0.0.1") + val services: Kleisli[({type λ[β$0$] = OptionT[IO, β$0$]})#λ, Request[IO], Response[IO]] = + code.api.v7_0_0.Http4s700.wrappedRoutesV700Services + + val httpApp: Kleisli[IO, Request[IO], Response[IO]] = (services).orNotFound override def run(args: List[String]): IO[ExitCode] = EmberServerBuilder .default[IO] From da29c29c4097f6c19c101e06d062d5fdf6448172 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 17 Dec 2025 23:51:37 +0100 Subject: [PATCH 15/37] feature/Support API version 7.0.0 - Add `v7_0_0` to supported API versions in `ApiVersionUtils` - Update `Http4s700` to return pre-defined resource docs instead of scanning for version 7.0.0 --- obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala | 2 ++ obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala b/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala index 5e93b6f7b..f7285febb 100644 --- a/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala +++ b/obp-api/src/main/scala/code/api/util/ApiVersionUtils.scala @@ -19,6 +19,7 @@ object ApiVersionUtils { v5_0_0 :: v5_1_0 :: v6_0_0 :: + v7_0_0 :: `dynamic-endpoint` :: `dynamic-entity` :: scannedApis @@ -41,6 +42,7 @@ object ApiVersionUtils { case v5_0_0.fullyQualifiedVersion | v5_0_0.apiShortVersion => v5_0_0 case v5_1_0.fullyQualifiedVersion | v5_1_0.apiShortVersion => v5_1_0 case v6_0_0.fullyQualifiedVersion | v6_0_0.apiShortVersion => v6_0_0 + case v7_0_0.fullyQualifiedVersion | v7_0_0.apiShortVersion => v7_0_0 case `dynamic-endpoint`.fullyQualifiedVersion | `dynamic-endpoint`.apiShortVersion => `dynamic-endpoint` case `dynamic-entity`.fullyQualifiedVersion | `dynamic-entity`.apiShortVersion => `dynamic-entity` case version if(scannedApis.map(_.fullyQualifiedVersion).contains(version)) 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 40fdbb5b2..d491e7be3 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 @@ -8,7 +8,7 @@ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ -import code.api.util.{ApiVersionUtils, CustomJsonFormats, NewStyle, ScannedApis} +import code.api.util.{ApiVersionUtils, CustomJsonFormats, NewStyle} import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v4_0_0.JSONFactory400 import com.github.dwickern.macros.NameOf.nameOf @@ -58,7 +58,7 @@ object Http4s700 { private def getResourceDocsList(requestedApiVersion: ApiVersion): List[ResourceDoc] = { requestedApiVersion match { case version: ScannedApiVersion => - ScannedApis.versionMapScannedApis.get(version).map(_.allResourceDocs.toList).getOrElse(Nil) + resourceDocs.toList case _ => Nil } } From 4a0eded98cd7a0727f793e8e89d61abbf71ea437 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 18 Dec 2025 09:07:24 +0100 Subject: [PATCH 16/37] refactor/Http4sServer: Update default http4s.port from 8181 to 8086 --- obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index 72b0574d2..8207e7268 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -14,7 +14,7 @@ object Http4sServer extends IOApp { //Start OBP relevant objects and settings; this step MUST be executed first new bootstrap.http4s.Http4sBoot().boot - val port = APIUtil.getPropsAsIntValue("http4s.port",8181) + val port = APIUtil.getPropsAsIntValue("http4s.port",8086) val host = APIUtil.getPropsValue("http4s.host","127.0.0.1") val services: Kleisli[({type λ[β$0$] = OptionT[IO, β$0$]})#λ, Request[IO], Response[IO]] = From f816f230b52b04f14a6a2d9e6dc1969a6531d974 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 18 Dec 2025 09:18:02 +0100 Subject: [PATCH 17/37] feature/Enhance resource docs handling for v7.0.0 - Update `getResourceDocsList` to include v7.0.0 in `ResourceDocsAPIMethods` - Modify `Http4s700` to utilize centralized `ResourceDocs140` for fetching resource docs - Simplify resource docs filtering logic for v7.0.0 with tailored handling --- .../ResourceDocsAPIMethods.scala | 87 ++++++++++--------- .../scala/code/api/v7_0_0/Http4s700.scala | 11 +-- 2 files changed, 47 insertions(+), 51 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index fc6c5995e..bf95b10b0 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -1,8 +1,10 @@ package code.api.ResourceDocs1_4_0 -import code.api.Constant.{GET_DYNAMIC_RESOURCE_DOCS_TTL, GET_STATIC_RESOURCE_DOCS_TTL, PARAM_LOCALE, HostName} +import code.api.Constant.{GET_DYNAMIC_RESOURCE_DOCS_TTL, GET_STATIC_RESOURCE_DOCS_TTL, HostName, PARAM_LOCALE} import code.api.OBPRestHelper import code.api.cache.Caching +import code.api.dynamic.endpoint.OBPAPIDynamicEndpoint +import code.api.dynamic.entity.OBPAPIDynamicEntity import code.api.util.APIUtil._ import code.api.util.ApiRole.{canReadDynamicResourceDocsAtOneBank, canReadResourceDoc} import code.api.util.ApiTag._ @@ -20,12 +22,9 @@ import code.api.v4_0_0.{APIMethods400, OBPAPI4_0_0} import code.api.v5_0_0.OBPAPI5_0_0 import code.api.v5_1_0.OBPAPI5_1_0 import code.api.v6_0_0.OBPAPI6_0_0 -import code.api.dynamic.endpoint.OBPAPIDynamicEndpoint -import code.api.dynamic.entity.OBPAPIDynamicEntity import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider import code.util.Helper import code.util.Helper.{MdcLoggable, ObpS, SILENCE_IS_GOLDEN} -import net.liftweb.http.S import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.enums.ContentParam import com.openbankproject.commons.model.enums.ContentParam.{ALL, DYNAMIC, STATIC} @@ -33,7 +32,7 @@ import com.openbankproject.commons.model.{BankId, ListResult, User} import com.openbankproject.commons.util.ApiStandards._ import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} import net.liftweb.common.{Box, Empty, Full} -import net.liftweb.http.{InMemoryResponse, LiftRules, PlainTextResponse} +import net.liftweb.http.{InMemoryResponse, LiftRules, PlainTextResponse, S} import net.liftweb.json import net.liftweb.json.JsonAST.{JField, JString, JValue} import net.liftweb.json._ @@ -118,6 +117,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth logger.debug(s"getResourceDocsList says requestedApiVersion is $requestedApiVersion") val resourceDocs = requestedApiVersion match { + case ApiVersion.v7_0_0 => code.api.v7_0_0.Http4s700.resourceDocs case ApiVersion.v6_0_0 => OBPAPI6_0_0.allResourceDocs case ApiVersion.v5_1_0 => OBPAPI5_1_0.allResourceDocs case ApiVersion.v5_0_0 => OBPAPI5_0_0.allResourceDocs @@ -139,6 +139,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth logger.debug(s"There are ${resourceDocs.length} resource docs available to $requestedApiVersion") val versionRoutes = requestedApiVersion match { + case ApiVersion.v7_0_0 => Nil case ApiVersion.v6_0_0 => OBPAPI6_0_0.routes case ApiVersion.v5_1_0 => OBPAPI5_1_0.routes case ApiVersion.v5_0_0 => OBPAPI5_0_0.routes @@ -165,7 +166,10 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val versionRoutesClasses = versionRoutes.map { vr => vr.getClass } // Only return the resource docs that have available routes - val activeResourceDocs = resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass)) + val activeResourceDocs = requestedApiVersion match { + case ApiVersion.v7_0_0 => resourceDocs + case _ => resourceDocs.filter(rd => versionRoutesClasses.contains(rd.partialFunction.getClass)) + } logger.debug(s"There are ${activeResourceDocs.length} resource docs available to $requestedApiVersion") @@ -176,8 +180,8 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth requestedApiVersion match { // only `obp` standard show the `localResourceDocs` - case version: ScannedApiVersion - if(version.apiStandard == obp.toString) => + case version: ScannedApiVersion + if(version.apiStandard == obp.toString) => activePlusLocalResourceDocs ++= localResourceDocs case _ => ; // all other standards only show their own apis. } @@ -218,7 +222,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth /** - * + * * @param requestedApiVersion * @param resourceDocTags * @param partialFunctionNames @@ -285,9 +289,9 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth val allDocs = staticDocs.map(_ ++ filteredDocs) resourceDocsToResourceDocJson(allDocs, resourceDocTags, partialFunctionNames, isVersion4OrHigher, locale) - + } - + def getResourceDocsObpDynamicCached( resourceDocTags: Option[List[ResourceDocTag]], partialFunctionNames: Option[List[String]], @@ -322,7 +326,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth } resourceDocsToResourceDocJson(Some(filteredDocs), resourceDocTags, partialFunctionNames, isVersion4OrHigher, locale) - + } @@ -347,7 +351,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth def getResourceDocsDescription(isBankLevelResourceDoc: Boolean) = { val endpointBankIdPath = if (isBankLevelResourceDoc) "/banks/BANK_ID" else "" - + s"""Get documentation about the RESTful resources on this server including example bodies for POST and PUT requests. | |This is the native data format used to document OBP endpoints. Each endpoint has a Resource Doc (a Scala case class) defined in the source code. @@ -368,8 +372,8 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth | if set content=dynamic, only show dynamic endpoints, if content=static, only show the static endpoints. if omit this parameter, we will show all the endpoints. | | You may need some other language resource docs, now we support en_GB and es_ES at the moment. - | - | You can filter with api-collection-id, but api-collection-id can not be used with others together. If api-collection-id is used in URL, it will ignore all other parameters. + | + | You can filter with api-collection-id, but api-collection-id can not be used with others together. If api-collection-id is used in URL, it will ignore all other parameters. | |See the Resource Doc endpoint for more information. | @@ -396,8 +400,8 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth | """ } - - + + localResourceDocs += ResourceDoc( getResourceDocsObp, implementedInApiVersion, @@ -407,7 +411,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth "Get Resource Docs.", getResourceDocsDescription(false), EmptyBody, - EmptyBody, + EmptyBody, UnknownError :: Nil, List(apiTagDocumentation, apiTagApi), Some(List(canReadResourceDoc)) @@ -424,7 +428,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth getApiLevelResourceDocs(cc,requestedApiVersionString, tags, partialFunctions, locale, contentParam, apiCollectionIdParam,false) } } - + localResourceDocs += ResourceDoc( getResourceDocsObpV400, implementedInApiVersion, @@ -439,7 +443,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth List(apiTagDocumentation, apiTagApi), Some(List(canReadResourceDoc)) ) - + lazy val getResourceDocsObpV400 : OBPEndpoint = { case "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() @@ -490,7 +494,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth Some(isVersion4OrHigher) ) json <- locale match { - case _ if (apiCollectionIdParam.isDefined) => + case _ if (apiCollectionIdParam.isDefined) => NewStyle.function.tryons(s"$UnknownError Can not prepare OBP resource docs.", 500, callContext) { val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(apiCollectionIdParam.getOrElse("")).map(_.operationId).map(getObpFormatOperationId) val resourceDocs = ResourceDoc.getResourceDocs(operationIds) @@ -647,7 +651,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth |See the Resource Doc endpoint for more information. | | Note: Resource Docs are cached, TTL is ${GET_DYNAMIC_RESOURCE_DOCS_TTL} seconds - | + | |Following are more examples: |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger |${getObpApiRoot}/v3.1.0/resource-docs/v3.1.0/swagger?tags=Account,Bank @@ -694,7 +698,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth Some(isVersion4OrHigher) ) cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) - + swaggerJValue <- if (cacheValueFromRedis.isDefined) { NewStyle.function.tryons(s"$UnknownError Can not convert internal swagger file from cache.", 400, cc.callContext) {json.parse(cacheValueFromRedis.get)} } else { @@ -747,7 +751,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth | • All endpoints are given one or more tags which are used for grouping | • Empty values will return error OBP-10053 | - |**functions** - Filter by function names (comma-separated list) + |**functions** - Filter by function names (comma-separated list) | • Example: ?functions=getBanks,bankById | • Each endpoint is implemented in the OBP Scala code by a 'function' | • Empty values will return error OBP-10054 @@ -815,26 +819,26 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth List(apiTagDocumentation, apiTagApi) ) - // Note: OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) - // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly + // Note: OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) + // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly // handle YAML content type. It provides the same functionality as the JSON endpoint // but returns OpenAPI documentation in YAML format instead of JSON. /** * OpenAPI 3.1 endpoint with comprehensive parameter validation. - * + * * This endpoint generates OpenAPI 3.1 documentation with the following validated query parameters: * - tags: Comma-separated list of tags to filter endpoints (e.g., ?tags=Account,Bank) * - functions: Comma-separated list of function names to filter endpoints - * - content: Filter type - "static", "dynamic", or "all" + * - content: Filter type - "static", "dynamic", or "all" * - locale: Language code for localization (e.g., "en_GB", "es_ES") * - api-collection-id: UUID to filter by specific API collection - * + * * Parameter validation guards ensure: * - Empty parameters (e.g., ?tags=) return 400 error * - Invalid content values return 400 error with valid options * - All parameters are properly trimmed and sanitized - * + * * Examples: * - ?content=static&tags=Account-Firehose * - ?tags=Account,Bank&functions=getBanks,bankById @@ -844,7 +848,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth case "resource-docs" :: requestedApiVersionString :: "openapi" :: Nil JsonGet _ => { cc => { implicit val ec = EndpointContext(Some(cc)) - + // Early validation for empty parameters using underlying S to bypass ObpS filtering if (S.param("tags").exists(_.trim.isEmpty)) { Full(errorJsonResponse(InvalidTagsParameter, 400)) @@ -888,7 +892,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth Some(isVersion4OrHigher) ) cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) - + openApiJValue <- if (cacheValueFromRedis.isDefined) { NewStyle.function.tryons(s"$UnknownError Can not convert internal openapi file from cache.", 400, cc.callContext) {json.parse(cacheValueFromRedis.get)} } else { @@ -922,8 +926,8 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth } } - // Note: The OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) - // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly + // Note: The OpenAPI 3.1 YAML endpoint (/resource-docs/API_VERSION/openapi.yaml) + // is implemented using Lift's serve mechanism in ResourceDocs140.scala to properly // handle YAML content type and response format, rather than as a standard OBPEndpoint. @@ -1022,7 +1026,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth example_request_body = endpointMappingRequestBodyExample, success_response_body = endpointMappingRequestBodyExample ) - + case doc if ( doc.operation_id == buildOperationId(APIMethods400.Implementations4_0_0.implementedInApiVersion, nameOf(APIMethods400.Implementations4_0_0.getDynamicEndpoint)) || doc.operation_id == buildOperationId(APIMethods400.Implementations4_0_0.implementedInApiVersion, nameOf(APIMethods400.Implementations4_0_0.getBankLevelDynamicEndpoint))) => doc.copy(success_response_body = ExampleValue.dynamicEndpointResponseBodyEmptyExample) @@ -1158,7 +1162,7 @@ object ResourceDocsAPIMethodsUtil extends MdcLoggable{ } logger.debug(s"partialFunctionNames is $partialFunctionNames") - val locale = ObpS.param(PARAM_LOCALE).or(ObpS.param("language")) // we used language before, so keep it there. + val locale = ObpS.param(PARAM_LOCALE).or(ObpS.param("language")) // we used language before, so keep it there. logger.debug(s"locale is $locale") // So we can produce a reduced list of resource docs to prevent manual editing of swagger files. @@ -1173,8 +1177,8 @@ object ResourceDocsAPIMethodsUtil extends MdcLoggable{ if x.trim.nonEmpty } yield x.trim logger.debug(s"apiCollectionIdParam is $apiCollectionIdParam") - - + + (tags, partialFunctionNames, locale, contentParam, apiCollectionIdParam) } @@ -1186,8 +1190,8 @@ We don't assume a default catalog (as API Explorer does) so the caller must specify any required filtering by catalog explicitly. */ def filterResourceDocs( - allResources: List[ResourceDoc], - resourceDocTags: Option[List[ResourceDocTag]], + allResources: List[ResourceDoc], + resourceDocTags: Option[List[ResourceDocTag]], partialFunctionNames: Option[List[String]] ) : List[ResourceDoc] = { @@ -1229,7 +1233,7 @@ so the caller must specify any required filtering by catalog explicitly. // tags param was not mentioned in url or was empty, so return all case None => filteredResources3 } - + val resourcesToUse = filteredResources4.toSet.toList @@ -1251,4 +1255,3 @@ so the caller must specify any required filtering by catalog explicitly. } - 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 d491e7be3..1f8388ebd 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 @@ -3,8 +3,8 @@ package code.api.v7_0_0 import cats.data.{Kleisli, OptionT} import cats.effect._ import code.api.Constant._ -import code.api.ResourceDocs1_4_0.ResourceDocsAPIMethodsUtil import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -55,13 +55,6 @@ object Http4s700 { // Common prefix: /obp/v7.0.0 val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString - private def getResourceDocsList(requestedApiVersion: ApiVersion): List[ResourceDoc] = { - requestedApiVersion match { - case version: ScannedApiVersion => - resourceDocs.toList - case _ => Nil - } - } resourceDocs += ResourceDoc( null, @@ -146,7 +139,7 @@ object Http4s700 { tags = tagsParam.map(_.map(ResourceDocTag(_))) functions = functionsParam.map(_.toList) requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) - resourceDocs = getResourceDocsList(requestedApiVersion) + resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) } yield convertAnyToJsonString(resourceDocsJson) From 46bf0ddcfea674d5f2de07e35f44d98a5fe2f0d2 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 18 Dec 2025 14:32:29 +0100 Subject: [PATCH 18/37] docfix/Update .gitignore to exclude `.trae` files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b52072215..c057cc52c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ *.code-workspace .zed .cursor +.trae .classpath .project .cache From 2ab1f4ff3effd5efac3860f9f074b56918ae8652 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 19 Dec 2025 09:06:24 +0100 Subject: [PATCH 19/37] test/ApiVersionUtilsTest: Update expected version count to 25 --- obp-api/src/test/scala/code/util/ApiVersionUtilsTest.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/test/scala/code/util/ApiVersionUtilsTest.scala b/obp-api/src/test/scala/code/util/ApiVersionUtilsTest.scala index 1a14ed896..05d1bd510 100644 --- a/obp-api/src/test/scala/code/util/ApiVersionUtilsTest.scala +++ b/obp-api/src/test/scala/code/util/ApiVersionUtilsTest.scala @@ -20,6 +20,6 @@ class ApiVersionUtilsTest extends V400ServerSetup { versions.map(version => ApiVersionUtils.valueOf(version.fullyQualifiedVersion)) //NOTE, when we added the new version, better fix this number manually. and also check the versions - versions.length shouldBe(24) + versions.length shouldBe(25) }} } \ No newline at end of file From bb8af5059d17cbb38a3fb5ee18a1e82e655e5b0c Mon Sep 17 00:00:00 2001 From: karmaking Date: Mon, 5 Jan 2026 13:24:15 +0100 Subject: [PATCH 20/37] fix restore build pipeline --- .github/workflows/auto_update_base_image.yml | 35 ++++++ .../build_container_develop_branch.yml | 82 ++++++------- .../build_container_non_develop_branch.yml | 114 ++++++++++++++++++ .github/workflows/run_trivy.yml | 54 +++++++++ 4 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/auto_update_base_image.yml create mode 100644 .github/workflows/build_container_non_develop_branch.yml create mode 100644 .github/workflows/run_trivy.yml diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml new file mode 100644 index 000000000..3048faf15 --- /dev/null +++ b/.github/workflows/auto_update_base_image.yml @@ -0,0 +1,35 @@ +name: Regular base image update check +on: + schedule: + - cron: "0 5 * * *" + workflow_dispatch: + +env: + ## Sets environment variable + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Docker Image Update Checker + id: baseupdatecheck + uses: lucacome/docker-image-update-checker@v2.0.0 + with: + base-image: jetty:9.4-jdk11-alpine + image: ${{ env.DOCKER_HUB_ORGANIZATION }}/obp-api:latest + + - name: Trigger build_container_develop_branch workflow + uses: actions/github-script@v6 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'build_container_develop_branch.yml', + ref: 'refs/heads/develop' + }); + if: steps.baseupdatecheck.outputs.needs-updating == 'true' diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index a5e8a87dc..d3f355042 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -1,15 +1,19 @@ -name: Build and publish container non develop +name: Build and publish container develop +# read-write repo token +# access to secrets on: + workflow_dispatch: push: branches: - - '*' - - '!develop' + - develop env: + ## Sets environment variable DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} DOCKER_HUB_REPOSITORY: obp-api + jobs: build: runs-on: ubuntu-latest @@ -29,9 +33,6 @@ jobs: --health-retries 5 steps: - uses: actions/checkout@v4 - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" - name: Set up JDK 11 uses: actions/setup-java@v4 with: @@ -40,7 +41,6 @@ jobs: cache: maven - name: Build with Maven run: | - set -o pipefail cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props @@ -76,44 +76,7 @@ jobs: echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log - - - name: Report failing tests (if any) - if: always() - run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 - fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." - exit 1 - else - echo "No failing tests detected in maven-build.log." - fi - - - name: Upload Maven build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: maven-build-log - if-no-files-found: ignore - path: | - maven-build.log - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - if-no-files-found: ignore - path: | - obp-api/target/surefire-reports/** - obp-commons/target/surefire-reports/** - **/target/scalatest-reports/** - **/target/site/surefire-report.html - **/target/site/surefire-report/* + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod - name: Save .war artifact run: | @@ -124,4 +87,33 @@ jobs: name: ${{ github.sha }} path: push/ + - name: Build the Docker image + run: | + echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io + docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop + docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC + docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags + echo docker done + + - uses: sigstore/cosign-installer@main + + - name: Write signing key to disk (only needed for `cosign sign --key`) + run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key + + - name: Sign container image + run: | + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC + env: + COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" + + diff --git a/.github/workflows/build_container_non_develop_branch.yml b/.github/workflows/build_container_non_develop_branch.yml new file mode 100644 index 000000000..946d81de4 --- /dev/null +++ b/.github/workflows/build_container_non_develop_branch.yml @@ -0,0 +1,114 @@ +name: Build and publish container non develop + +on: + push: + branches: + - '*' + - '!develop' + +env: + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + DOCKER_HUB_REPOSITORY: obp-api + +jobs: + build: + runs-on: ubuntu-latest + services: + # Label used to access the service container + redis: + # Docker Hub image + image: redis + ports: + # Opens tcp port 6379 on the host and service container + - 6379:6379 + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + cache: maven + - name: Build with Maven + run: | + cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props + echo connector=star > obp-api/src/main/resources/props/test.default.props + echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props + echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props + echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props + echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props + echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props + echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props + echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props + echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props + echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props + echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props + echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props + echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props + echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props + echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props + echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props + echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props + echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props + echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props + + echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + + echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props + echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props + + echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props + + echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod + + - name: Save .war artifact + run: | + mkdir -p ./push + cp obp-api/target/obp-api-1.*.war ./push/ + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.sha }} + path: push/ + + - name: Build the Docker image + run: | + echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io + docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} + docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC + docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags + echo docker done + + - uses: sigstore/cosign-installer@main + + - name: Write signing key to disk (only needed for `cosign sign --key`) + run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key + + - name: Sign container image + run: | + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA + env: + COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" + + + diff --git a/.github/workflows/run_trivy.yml b/.github/workflows/run_trivy.yml new file mode 100644 index 000000000..4636bd311 --- /dev/null +++ b/.github/workflows/run_trivy.yml @@ -0,0 +1,54 @@ +name: scan container image + +on: + workflow_run: + workflows: + - Build and publish container develop + - Build and publish container non develop + types: + - completed +env: + ## Sets environment variable + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + DOCKER_HUB_REPOSITORY: obp-api + + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + - uses: actions/checkout@v4 + - id: trivy-db + name: Check trivy db sha + env: + GH_TOKEN: ${{ github.token }} + run: | + endpoint='/orgs/aquasecurity/packages/container/trivy-db/versions' + headers='Accept: application/vnd.github+json' + jqFilter='.[] | select(.metadata.container.tags[] | contains("latest")) | .name | sub("sha256:";"")' + sha=$(gh api -H "${headers}" "${endpoint}" | jq --raw-output "${jqFilter}") + echo "Trivy DB sha256:${sha}" + echo "::set-output name=sha::${sha}" + - uses: actions/cache@v4 + with: + path: .trivy + key: ${{ runner.os }}-trivy-db-${{ steps.trivy-db.outputs.sha }} + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: 'docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}' + format: 'template' + template: '@/contrib/sarif.tpl' + output: 'trivy-results.sarif' + security-checks: 'vuln' + severity: 'CRITICAL,HIGH' + timeout: '30m' + cache-dir: .trivy + - name: Fix .trivy permissions + run: sudo chown -R $(stat . -c %u:%g) .trivy + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' \ No newline at end of file From 9e6cc0fb871619c9bd611c9afa621afd514e8de6 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 6 Jan 2026 12:16:57 +0100 Subject: [PATCH 21/37] docfix/tweaked the default port for http4s --- README.md | 2 +- obp-api/src/main/resources/props/sample.props.template | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6d92e9c2b..33e7df4c4 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" mvn -pl obp-http4s-runner -am java -jar obp-http4s-runner/target/obp-http4s-runner.jar ``` -The http4s server binds to `http4s.host` / `http4s.port` as configured in your props file (defaults are `127.0.0.1` and `8181`). +The http4s server binds to `http4s.host` / `http4s.port` as configured in your props file (defaults are `127.0.0.1` and `8086`). ### ZED IDE Setup diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 29e80e27b..d181a5a1f 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1691,6 +1691,6 @@ securelogging_mask_email=true ############################################ # Host and port for http4s server (used by bootstrap.http4s.Http4sServer) -# Defaults (if not set) are 127.0.0.1 and 8181 +# Defaults (if not set) are 127.0.0.1 and 8086 http4s.host=127.0.0.1 http4s.port=8086 \ No newline at end of file From 886bbf04f6e26f4ee3e878df5938c28871c862a7 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 6 Jan 2026 12:20:39 +0100 Subject: [PATCH 22/37] refactor/code clean --- obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index 8207e7268..7a2a42c1c 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -17,8 +17,7 @@ object Http4sServer extends IOApp { val port = APIUtil.getPropsAsIntValue("http4s.port",8086) val host = APIUtil.getPropsValue("http4s.host","127.0.0.1") - val services: Kleisli[({type λ[β$0$] = OptionT[IO, β$0$]})#λ, Request[IO], Response[IO]] = - code.api.v7_0_0.Http4s700.wrappedRoutesV700Services + val services: HttpRoutes[IO] = code.api.v7_0_0.Http4s700.wrappedRoutesV700Services val httpApp: Kleisli[IO, Request[IO], Response[IO]] = (services).orNotFound From e5dd7c4481f3d243dd05d30a76a398e242f60659 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 6 Jan 2026 15:47:38 +0100 Subject: [PATCH 23/37] flushall build and run runs http4s as well --- flushall_build_and_run.sh | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 833442508..6708a9ed1 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -1,10 +1,13 @@ #!/bin/bash -# Script to flush Redis, build the project, and run Jetty +# Script to flush Redis, build the project, and run both Jetty and http4s servers # # This script should be run from the OBP-API root directory: # cd /path/to/OBP-API # ./flushall_build_and_run.sh +# +# The http4s server will run in the background on port 8081 +# The Jetty server will run in the foreground on port 8080 set -e # Exit on error @@ -27,4 +30,29 @@ echo "==========================================" echo "Building and running with Maven..." echo "==========================================" export MAVEN_OPTS="-Xss128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED" -mvn install -pl .,obp-commons && mvn jetty:run -pl obp-api +mvn install -pl .,obp-commons + +echo "" +echo "==========================================" +echo "Building http4s runner..." +echo "==========================================" +export MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" +mvn -pl obp-http4s-runner -am clean package -DskipTests=true -Dmaven.test.skip=true + +echo "" +echo "==========================================" +echo "Starting http4s server in background..." +echo "==========================================" +java -jar obp-http4s-runner/target/obp-http4s-runner.jar > http4s-server.log 2>&1 & +HTTP4S_PID=$! +echo "http4s server started with PID: $HTTP4S_PID (port 8081)" +echo "Logs are being written to: http4s-server.log" +echo "" +echo "To stop http4s server later: kill $HTTP4S_PID" +echo "" + +echo "==========================================" +echo "Starting Jetty server (foreground)..." +echo "==========================================" +export MAVEN_OPTS="-Xss128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED" +mvn jetty:run -pl obp-api From b9ca1591f4787cb0be5a77446e5bc9e26b4814fe Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 6 Jan 2026 16:54:07 +0100 Subject: [PATCH 24/37] test/fixed failed tests --- obp-api/src/main/scala/code/api/util/ErrorMessages.scala | 1 + .../src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- .../test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala | 8 +++++--- 3 files changed, 7 insertions(+), 4 deletions(-) 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 4c17086c1..0a5110ebe 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -84,6 +84,7 @@ object ErrorMessages { val FXCurrencyCodeCombinationsNotSupported = "OBP-10004: ISO Currency code combination not supported for FX. Please modify the FROM_CURRENCY_CODE or TO_CURRENCY_CODE. " val InvalidDateFormat = "OBP-10005: Invalid Date Format. Could not convert value to a Date." val InvalidCurrency = "OBP-10006: Invalid Currency Value." + val InvalidCacheNamespaceId = "OBP-10123: Invalid namespace_id." val IncorrectRoleName = "OBP-10007: Incorrect Role name:" val CouldNotTransformJsonToInternalModel = "OBP-10008: Could not transform Json to internal model." val CountNotSaveOrUpdateResource = "OBP-10009: Could not save or update resource." diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 3f8a0d05e..b5b2c15b3 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -635,7 +635,7 @@ trait APIMethods600 { } namespaceId = postJson.namespace_id _ <- Helper.booleanToFuture( - s"Invalid namespace_id: $namespaceId. Valid values: ${Constant.ALL_CACHE_NAMESPACES.mkString(", ")}", + s"$InvalidCacheNamespaceId $namespaceId. Valid values: ${Constant.ALL_CACHE_NAMESPACES.mkString(", ")}", 400, callContext )(Constant.ALL_CACHE_NAMESPACES.contains(namespaceId)) diff --git a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala index 690464e06..4a446b032 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/LogCacheEndpointTest.scala @@ -1,7 +1,7 @@ package code.api.v5_1_0 import code.api.util.APIUtil.OAuth._ -import code.api.util.ApiRole.CanGetSystemLogCacheAll +import code.api.util.ApiRole.{CanGetSystemLogCacheAll,CanGetSystemLogCacheInfo} import code.api.util.ErrorMessages.{UserHasMissingRoles, UserNotLoggedIn} import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 import code.entitlement.Entitlement @@ -41,7 +41,9 @@ class LogCacheEndpointTest extends V510ServerSetup { val response = makeGetRequest(request) Then("error should be " + UserHasMissingRoles + CanGetSystemLogCacheAll) response.code should equal(403) - response.body.extract[ErrorMessage].message should be(UserHasMissingRoles + CanGetSystemLogCacheAll) + response.body.extract[ErrorMessage].message contains (UserHasMissingRoles) shouldBe (true) + response.body.extract[ErrorMessage].message contains CanGetSystemLogCacheInfo.toString() shouldBe (true) + response.body.extract[ErrorMessage].message contains CanGetSystemLogCacheAll.toString() shouldBe (true) } } @@ -129,7 +131,7 @@ class LogCacheEndpointTest extends V510ServerSetup { val response = makeGetRequest(request) Then("We should get a not found response since endpoint does not exist") - response.code should equal(404) + response.code should equal(400) val json = response.body.extract[JObject] And("The response should contain the correct error message") From 17f9677f1df0cd3f2ae665f11ecfbf694f6a7c1e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 6 Jan 2026 23:46:35 +0100 Subject: [PATCH 25/37] flushall_build_and_run only liftweb run once again --- flushall_build_and_run.sh | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 6708a9ed1..833442508 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -1,13 +1,10 @@ #!/bin/bash -# Script to flush Redis, build the project, and run both Jetty and http4s servers +# Script to flush Redis, build the project, and run Jetty # # This script should be run from the OBP-API root directory: # cd /path/to/OBP-API # ./flushall_build_and_run.sh -# -# The http4s server will run in the background on port 8081 -# The Jetty server will run in the foreground on port 8080 set -e # Exit on error @@ -30,29 +27,4 @@ echo "==========================================" echo "Building and running with Maven..." echo "==========================================" export MAVEN_OPTS="-Xss128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED" -mvn install -pl .,obp-commons - -echo "" -echo "==========================================" -echo "Building http4s runner..." -echo "==========================================" -export MAVEN_OPTS="-Xms3G -Xmx6G -XX:MaxMetaspaceSize=2G" -mvn -pl obp-http4s-runner -am clean package -DskipTests=true -Dmaven.test.skip=true - -echo "" -echo "==========================================" -echo "Starting http4s server in background..." -echo "==========================================" -java -jar obp-http4s-runner/target/obp-http4s-runner.jar > http4s-server.log 2>&1 & -HTTP4S_PID=$! -echo "http4s server started with PID: $HTTP4S_PID (port 8081)" -echo "Logs are being written to: http4s-server.log" -echo "" -echo "To stop http4s server later: kill $HTTP4S_PID" -echo "" - -echo "==========================================" -echo "Starting Jetty server (foreground)..." -echo "==========================================" -export MAVEN_OPTS="-Xss128m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang.invoke=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED" -mvn jetty:run -pl obp-api +mvn install -pl .,obp-commons && mvn jetty:run -pl obp-api From 4fe67750298b708961fb7c1c8d0f333632fa5b02 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 11 Jan 2026 23:00:00 +0100 Subject: [PATCH 26/37] CanGetMethodRoutingNames --- obp-api/src/main/scala/code/api/util/APIUtil.scala | 5 +++++ obp-api/src/main/scala/code/api/util/ApiRole.scala | 3 +++ obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 5 ++--- 3 files changed, 10 insertions(+), 3 deletions(-) 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 381b0c283..11ee5094c 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1635,6 +1635,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ var errorResponseBodies: List[String], // Possible error responses tags: List[ResourceDocTag], var roles: Option[List[ApiRole]] = None, + // IMPORTANT: Roles declared here are AUTOMATICALLY CHECKED at runtime! + // When roles specified, framework automatically: 1) Validates user authentication, + // 2) Checks user has at least one of specified roles, 3) Performs checks in wrappedWithAuthCheck() + // No manual hasEntitlement() call needed in endpoint body - handled automatically! + // To disable: call .disableAutoValidateRoles() on ResourceDoc isFeatured: Boolean = false, specialInstructions: Option[String] = None, var specifiedUrl: Option[String] = None, // A derived value: Contains the called version (added at run time). See the resource doc for resource doc! 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 9c7a990be..abbe92613 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1003,6 +1003,9 @@ object ApiRole extends MdcLoggable{ case class CanGetAllConnectorMethods(requiresBankId: Boolean = false) extends ApiRole lazy val canGetAllConnectorMethods = CanGetAllConnectorMethods() + case class CanGetConnectorMethodNames(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetConnectorMethodNames = CanGetConnectorMethodNames() + case class CanCreateDynamicResourceDoc(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateDynamicResourceDoc = CanCreateDynamicResourceDoc() diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index b5b2c15b3..14066eb81 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1648,7 +1648,7 @@ trait APIMethods600 { | |${userAuthenticationMessage(true)} | - |CanGetMethodRoutings entitlement is required. + |CanGetConnectorMethodNames entitlement is required. | """.stripMargin, EmptyBody, @@ -1659,7 +1659,7 @@ trait APIMethods600 { UnknownError ), List(apiTagSystem, apiTagMethodRouting, apiTagApi), - Some(List(canGetMethodRoutings)) + Some(List(canGetConnectorMethodNames)) ) lazy val getConnectorMethodNames: OBPEndpoint = { @@ -1667,7 +1667,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", u.userId, canGetMethodRoutings, callContext) // Fetch connector method names with caching methodNames <- Future { /** From 8698c8c0b50323db4ed65041768b65fc28ac013e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 11 Jan 2026 23:24:31 +0100 Subject: [PATCH 27/37] Changed role name to canGetSystemConnectorMethodNames --- .../main/scala/code/api/util/ApiRole.scala | 128 +++++++++--------- .../scala/code/api/v6_0_0/APIMethods600.scala | 6 +- 2 files changed, 67 insertions(+), 67 deletions(-) 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 abbe92613..7a107307e 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -86,19 +86,19 @@ object ApiRole extends MdcLoggable{ case class CanGetCustomersAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCustomersAtAllBanks = CanGetCustomersAtAllBanks() - + case class CanGetCustomersMinimalAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCustomersMinimalAtAllBanks = CanGetCustomersMinimalAtAllBanks() - + case class CanGetCustomersAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomersAtOneBank = CanGetCustomersAtOneBank() - + case class CanGetCustomersMinimalAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomersMinimalAtOneBank = CanGetCustomersMinimalAtOneBank() - + case class CanGetCustomerOverview(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomerOverview = CanGetCustomerOverview() - + case class CanGetCustomerOverviewFlat(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomerOverviewFlat = CanGetCustomerOverviewFlat() @@ -124,10 +124,10 @@ object ApiRole extends MdcLoggable{ // ALL case class CanGetSystemLogCacheAll(requiresBankId: Boolean = false) extends ApiRole lazy val canGetSystemLogCacheAll = CanGetSystemLogCacheAll() - + case class CanUpdateAgentStatusAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateAgentStatusAtAnyBank = CanUpdateAgentStatusAtAnyBank() - + case class CanUpdateAgentStatusAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateAgentStatusAtOneBank = CanUpdateAgentStatusAtOneBank() @@ -136,10 +136,10 @@ object ApiRole extends MdcLoggable{ case class CanUpdateCustomerNumber(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateCustomerNumber = CanUpdateCustomerNumber() - + case class CanUpdateCustomerMobilePhoneNumber(requiresBankId: Boolean = true) extends ApiRole - lazy val canUpdateCustomerMobilePhoneNumber = CanUpdateCustomerMobilePhoneNumber() - + lazy val canUpdateCustomerMobilePhoneNumber = CanUpdateCustomerMobilePhoneNumber() + case class CanUpdateCustomerIdentity(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateCustomerIdentity = CanUpdateCustomerIdentity() @@ -160,28 +160,28 @@ object ApiRole extends MdcLoggable{ case class CanCreateCustomerAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateCustomerAtAnyBank = CanCreateCustomerAtAnyBank() - + case class CanGetCorrelatedUsersInfo(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetCorrelatedUsersInfo = CanGetCorrelatedUsersInfo() - + lazy val canGetCorrelatedUsersInfo = CanGetCorrelatedUsersInfo() + case class CanGetCorrelatedUsersInfoAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCorrelatedUsersInfoAtAnyBank = CanGetCorrelatedUsersInfoAtAnyBank() case class CanCreateUserCustomerLink(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateUserCustomerLink = CanCreateUserCustomerLink() - + case class CanDeleteUserCustomerLink(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteUserCustomerLink = CanDeleteUserCustomerLink() - + case class CanGetUserCustomerLink(requiresBankId: Boolean = true) extends ApiRole lazy val canGetUserCustomerLink = CanGetUserCustomerLink() case class CanCreateUserCustomerLinkAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateUserCustomerLinkAtAnyBank = CanCreateUserCustomerLinkAtAnyBank() - + case class CanGetUserCustomerLinkAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canGetUserCustomerLinkAtAnyBank = CanGetUserCustomerLinkAtAnyBank() - + case class CanDeleteUserCustomerLinkAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteUserCustomerLinkAtAnyBank = CanDeleteUserCustomerLinkAtAnyBank() @@ -193,10 +193,10 @@ object ApiRole extends MdcLoggable{ case class CanCreateAccountAttributeAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateAccountAttributeAtOneBank = CanCreateAccountAttributeAtOneBank() - + case class CanUpdateAccountAttribute(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateAccountAttribute = CanUpdateAccountAttribute() - + case class CanGetAnyUser (requiresBankId: Boolean = false) extends ApiRole lazy val canGetAnyUser = CanGetAnyUser() @@ -226,10 +226,10 @@ object ApiRole extends MdcLoggable{ case class CanCreateEntitlementAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateEntitlementAtOneBank = CanCreateEntitlementAtOneBank() - + case class CanCreateSystemViewPermission(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateSystemViewPermission = CanCreateSystemViewPermission() - + case class CanDeleteSystemViewPermission(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteSystemViewPermission = CanDeleteSystemViewPermission() @@ -305,16 +305,16 @@ object ApiRole extends MdcLoggable{ case class CanGetCustomerAccountLink(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomerAccountLink = CanGetCustomerAccountLink() - + case class CanGetCustomerAccountLinks(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomerAccountLinks = CanGetCustomerAccountLinks() - + case class CanCreateBranch(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateBranch = CanCreateBranch() case class CanUpdateBranch(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateBranch = CanUpdateBranch() - + case class CanCreateBranchAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateBranchAtAnyBank = CanCreateBranchAtAnyBank() @@ -325,14 +325,14 @@ object ApiRole extends MdcLoggable{ lazy val canDeleteBranchAtAnyBank = CanDeleteBranchAtAnyBank() case class CanCreateAtm(requiresBankId: Boolean = true) extends ApiRole - lazy val canCreateAtm = CanCreateAtm() - + lazy val canCreateAtm = CanCreateAtm() + case class CanDeleteAtm(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteAtm = CanDeleteAtm() case class CanDeleteAtmAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteAtmAtAnyBank = CanDeleteAtmAtAnyBank() - + case class CanUpdateAtm(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateAtm = CanUpdateAtm() @@ -344,22 +344,22 @@ object ApiRole extends MdcLoggable{ case class CanCreateCounterparty(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateCounterparty = CanCreateCounterparty() - + case class CanCreateCounterpartyAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateCounterpartyAtAnyBank = CanCreateCounterpartyAtAnyBank() case class CanDeleteCounterparty(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteCounterparty = CanDeleteCounterparty() - + case class CanDeleteCounterpartyAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteCounterpartyAtAnyBank = CanDeleteCounterpartyAtAnyBank() - + case class CanGetCounterparty(requiresBankId: Boolean = true) extends ApiRole - lazy val canGetCounterparty = CanGetCounterparty() - + lazy val canGetCounterparty = CanGetCounterparty() + case class CanGetCounterpartiesAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCounterpartiesAtAnyBank = CanGetCounterpartiesAtAnyBank() - + case class CanGetCounterparties(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCounterparties = CanGetCounterparties() @@ -368,10 +368,10 @@ object ApiRole extends MdcLoggable{ case class CanGetAllApiCollections(requiresBankId: Boolean = false) extends ApiRole lazy val canGetAllApiCollections = CanGetAllApiCollections() - + case class CanGetCounterpartyAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCounterpartyAtAnyBank = CanGetCounterpartyAtAnyBank() - + case class CanCreateProduct(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateProduct = CanCreateProduct() @@ -395,7 +395,7 @@ object ApiRole extends MdcLoggable{ case class CanReadMetrics (requiresBankId: Boolean = false) extends ApiRole lazy val canReadMetrics = CanReadMetrics() - + case class CanGetMetricsAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetMetricsAtOneBank = CanGetMetricsAtOneBank() @@ -419,19 +419,19 @@ object ApiRole extends MdcLoggable{ case class CanDeleteCacheKey(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteCacheKey = CanDeleteCacheKey() lazy val canGetConfig = CanGetConfig() - + case class CanGetAdapterInfo(requiresBankId: Boolean = false) extends ApiRole lazy val canGetAdapterInfo = CanGetAdapterInfo() - + case class CanGetAdapterInfoAtOneBank(requiresBankId: Boolean = false) extends ApiRole lazy val canGetAdapterInfoAtOneBank = CanGetAdapterInfoAtOneBank() - + case class CanGetDatabaseInfo(requiresBankId: Boolean = false) extends ApiRole lazy val canGetDatabaseInfo = CanGetDatabaseInfo() - + case class CanGetMigrations(requiresBankId: Boolean = false) extends ApiRole lazy val canGetMigrations = CanGetMigrations() - + case class CanGetCallContext(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCallContext = CanGetCallContext() @@ -446,10 +446,10 @@ object ApiRole extends MdcLoggable{ case class CanUseAccountFirehoseAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canUseAccountFirehoseAtAnyBank = CanUseAccountFirehoseAtAnyBank() - + case class CanUseAccountFirehose(requiresBankId: Boolean = true) extends ApiRole lazy val canUseAccountFirehose = CanUseAccountFirehose() - + case class CanUseCustomerFirehoseAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canUseCustomerFirehoseAtAnyBank = CanUseCustomerFirehoseAtAnyBank() @@ -467,38 +467,38 @@ object ApiRole extends MdcLoggable{ case class CanUnlockUser (requiresBankId: Boolean = false) extends ApiRole lazy val canUnlockUser = CanUnlockUser() - + case class CanLockUser (requiresBankId: Boolean = false) extends ApiRole lazy val canLockUser = CanLockUser() - + case class CanDeleteUser (requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteUser = CanDeleteUser() case class CanValidateUser (requiresBankId: Boolean = false) extends ApiRole lazy val canValidateUser = CanValidateUser() - + case class CanGetUsersWithAttributes (requiresBankId: Boolean = false) extends ApiRole lazy val canGetUsersWithAttributes = CanGetUsersWithAttributes() - + case class CanCreateNonPersonalUserAttribute (requiresBankId: Boolean = false) extends ApiRole lazy val canCreateNonPersonalUserAttribute = CanCreateNonPersonalUserAttribute() - + case class CanGetNonPersonalUserAttributes (requiresBankId: Boolean = false) extends ApiRole lazy val canGetNonPersonalUserAttributes = CanGetNonPersonalUserAttributes() - + case class CanDeleteNonPersonalUserAttribute (requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteNonPersonalUserAttribute = CanDeleteNonPersonalUserAttribute() // v6.0.0 User Attribute roles (consistent naming - "user attributes" means non-personal) case class CanCreateUserAttribute (requiresBankId: Boolean = false) extends ApiRole lazy val canCreateUserAttribute = CanCreateUserAttribute() - + case class CanGetUserAttributes (requiresBankId: Boolean = false) extends ApiRole lazy val canGetUserAttributes = CanGetUserAttributes() - + case class CanUpdateUserAttribute (requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateUserAttribute = CanUpdateUserAttribute() - + case class CanDeleteUserAttribute (requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteUserAttribute = CanDeleteUserAttribute() @@ -510,7 +510,7 @@ object ApiRole extends MdcLoggable{ case class CanCreateRateLimits(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateRateLimits = CanCreateRateLimits() - + case class CanDeleteRateLimits(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteRateLimits = CanDeleteRateLimits() @@ -1003,8 +1003,8 @@ object ApiRole extends MdcLoggable{ case class CanGetAllConnectorMethods(requiresBankId: Boolean = false) extends ApiRole lazy val canGetAllConnectorMethods = CanGetAllConnectorMethods() - case class CanGetConnectorMethodNames(requiresBankId: Boolean = false) extends ApiRole - lazy val canGetConnectorMethodNames = CanGetConnectorMethodNames() + case class CanGetSystemConnectorMethodNames(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSystemConnectorMethodNames = CanGetSystemConnectorMethodNames() case class CanCreateDynamicResourceDoc(requiresBankId: Boolean = false) extends ApiRole lazy val canCreateDynamicResourceDoc = CanCreateDynamicResourceDoc() @@ -1173,17 +1173,17 @@ object ApiRole extends MdcLoggable{ lazy val canCreateGroupAtAllBanks = CanCreateGroupAtAllBanks() case class CanCreateGroupAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateGroupAtOneBank = CanCreateGroupAtOneBank() - + case class CanUpdateGroupAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateGroupAtAllBanks = CanUpdateGroupAtAllBanks() case class CanUpdateGroupAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canUpdateGroupAtOneBank = CanUpdateGroupAtOneBank() - + case class CanDeleteGroupAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteGroupAtAllBanks = CanDeleteGroupAtAllBanks() case class CanDeleteGroupAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canDeleteGroupAtOneBank = CanDeleteGroupAtOneBank() - + case class CanGetGroupsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canGetGroupsAtAllBanks = CanGetGroupsAtAllBanks() case class CanGetGroupsAtOneBank(requiresBankId: Boolean = true) extends ApiRole @@ -1194,12 +1194,12 @@ object ApiRole extends MdcLoggable{ lazy val canAddUserToGroupAtAllBanks = CanAddUserToGroupAtAllBanks() case class CanAddUserToGroupAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canAddUserToGroupAtOneBank = CanAddUserToGroupAtOneBank() - + case class CanRemoveUserFromGroupAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canRemoveUserFromGroupAtAllBanks = CanRemoveUserFromGroupAtAllBanks() case class CanRemoveUserFromGroupAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canRemoveUserFromGroupAtOneBank = CanRemoveUserFromGroupAtOneBank() - + case class CanGetUserGroupMembershipsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canGetUserGroupMembershipsAtAllBanks = CanGetUserGroupMembershipsAtAllBanks() case class CanGetUserGroupMembershipsAtOneBank(requiresBankId: Boolean = true) extends ApiRole @@ -1282,15 +1282,15 @@ object Util { "CanSetCallLimits", "CanDeleteRateLimits" ) - + val allowed = allowedPrefixes ::: allowedExistingNames source.collect { case obj: Defn.Object if obj.name.value == "ApiRole" => obj.collect { - case c: Defn.Class if allowed.exists(i => c.name.syntax.startsWith(i)) == true => + case c: Defn.Class if allowed.exists(i => c.name.syntax.startsWith(i)) == true => // OK - case c: Defn.Class if allowed.exists(i => c.name.syntax.startsWith(i)) == false => + case c: Defn.Class if allowed.exists(i => c.name.syntax.startsWith(i)) == false => println("INCORRECT - " + c) } } @@ -1300,4 +1300,4 @@ object Util { checkWrongDefinedNames } -} \ No newline at end of file +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 14066eb81..cd9b9aab0 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1648,7 +1648,7 @@ trait APIMethods600 { | |${userAuthenticationMessage(true)} | - |CanGetConnectorMethodNames entitlement is required. + |CanGetSystemConnectorMethodNames entitlement is required. | """.stripMargin, EmptyBody, @@ -1658,8 +1658,8 @@ trait APIMethods600 { UserHasMissingRoles, UnknownError ), - List(apiTagSystem, apiTagMethodRouting, apiTagApi), - Some(List(canGetConnectorMethodNames)) + List(apiTagConnectorMethod, apiTagSystem, apiTagMethodRouting, apiTagApi), + Some(List(canGetSystemConnectorMethodNames)) ) lazy val getConnectorMethodNames: OBPEndpoint = { From 09e8c6c48f8ca3046a1bc7a633ff0a0bad49ce61 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Jan 2026 14:21:55 +0100 Subject: [PATCH 28/37] Docfix: Fewer ABA examples part 1 --- .../scala/code/api/v6_0_0/APIMethods600.scala | 554 ++---------------- 1 file changed, 40 insertions(+), 514 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index cd9b9aab0..40ab1fc7d 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5236,538 +5236,64 @@ trait APIMethods600 { ), examples = List( AbacRuleExampleJsonV600( - category = "User - Authenticated User", - title = "Check Email Domain", - code = "authenticatedUser.emailAddress.contains(\"@example.com\")", - description = "Verify that the authenticated user's email belongs to a specific domain" + category = "Access Control - Account Access", + title = "Branch Manager Internal Account Access", + code = "authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", + description = "Allow GET access to current accounts only when user's branch matches account's branch" ), AbacRuleExampleJsonV600( - category = "User - Authenticated User", - title = "Check Authentication Provider", - code = "authenticatedUser.provider == \"obp\"", - description = "Verify the authentication provider is OBP" + category = "Access Control - Transaction Access", + title = "Internal Network High-Value Transaction Review", + code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"compliance_officer\") && transactionOpt.exists(_.amount > 10000)", + description = "Allow compliance officers on internal network to review high-value transactions over 10,000" ), AbacRuleExampleJsonV600( - category = "User - Authenticated User", - title = "Compare Authenticated to Target User", - code = "authenticatedUser.userId == userOpt.get.userId", - description = "Check if authenticated user matches the target user (unsafe - use exists instead)" + category = "Access Control - Customer Data", + title = "Regional Manager Customer Access via Mobile", + code = "authenticatedUserAttributes.exists(a => a.name == \"region\" && customerAttributes.exists(ca => ca.name == \"region\" && a.value == ca.value)) && callContext.exists(_.userAgent.exists(_.contains(\"Mobile\"))) && customerOpt.exists(_.relationshipStatus == \"ACTIVE\")", + description = "Allow regional managers to access active customers in their region when using mobile app" ), AbacRuleExampleJsonV600( - category = "User - Authenticated User", - title = "Check User Not Deleted", - code = "!authenticatedUser.isDeleted.getOrElse(false)", - description = "Verify the authenticated user is not marked as deleted" + category = "Access Control - Transaction Modification", + title = "Authorized Delegation Transaction Update", + code = "onBehalfOfUserOpt.exists(_.userId != authenticatedUser.userId) && onBehalfOfUserAttributes.exists(a => a.name == \"delegation_level\" && a.value == \"full\") && callContext.exists(_.verb.exists(_ == \"PUT\")) && transactionOpt.exists(t => t.amount < 5000)", + description = "Allow full delegation to update transactions under 5000 via PUT requests" ), AbacRuleExampleJsonV600( - category = "User Attributes - Authenticated User", - title = "Check Admin Role", - code = "authenticatedUserAttributes.exists(attr => attr.name == \"role\" && attr.value == \"admin\")", - description = "Check if authenticated user has admin role attribute" + category = "Access Control - Account Balance", + title = "Department Head Same-Department Account Read", + code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"department_head\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance > 0)", + description = "Allow department heads to read account details for accounts in their department with positive balance" ), AbacRuleExampleJsonV600( - category = "User Attributes - Authenticated User", - title = "Check Department", - code = "authenticatedUserAttributes.find(_.name == \"department\").exists(_.value == \"finance\")", - description = "Check if authenticated user belongs to finance department" + category = "Access Control - Transaction Request Approval", + title = "Manager Internal Network Transaction Approval", + code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && List(\"manager\", \"supervisor\").contains(a.value)) && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", + description = "Allow managers/supervisors on internal network to approve pending transaction requests under 50,000" ), AbacRuleExampleJsonV600( - category = "User Attributes - Authenticated User", - title = "Check Multiple Roles", - code = "authenticatedUserAttributes.exists(attr => attr.name == \"role\" && List(\"admin\", \"manager\").contains(attr.value))", - description = "Check if authenticated user has admin or manager role" + category = "Access Control - Customer Onboarding", + title = "KYC Officer Customer Creation from Branch", + code = "authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", + description = "Allow KYC certified officers to create customers via POST from branch network (10.20.x.x) when status is pending" ), AbacRuleExampleJsonV600( - category = "User Auth Context", - title = "Check Session Type", - code = "authenticatedUserAuthContext.exists(_.key == \"session_type\" && _.value == \"secure\")", - description = "Verify the session type is secure" + category = "Access Control - Cross-Border Transaction", + title = "International Team Foreign Currency Transaction", + code = "authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", + description = "Allow international team to access foreign currency transactions under 100k on international-enabled accounts" ), AbacRuleExampleJsonV600( - category = "User Auth Context", - title = "Check Auth Method", - code = "authenticatedUserAuthContext.exists(_.key == \"auth_method\" && _.value == \"certificate\")", - description = "Verify authentication was done via certificate" + category = "Access Control - Delegated Account Management", + title = "Assistant with Limited Delegation Account View", + code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserAttributes.exists(a => a.name == \"role\" && a.value == \"executive\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", + description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of their assigned executive" ), AbacRuleExampleJsonV600( - category = "User - Delegation", - title = "Check Delegated User Email Domain", - code = "onBehalfOfUserOpt.exists(_.emailAddress.endsWith(\"@company.com\"))", - description = "Check if delegation user belongs to specific company domain" - ), - AbacRuleExampleJsonV600( - category = "User - Delegation", - title = "Check No Delegation or Self Delegation", - code = "onBehalfOfUserOpt.isEmpty || onBehalfOfUserOpt.get.userId == authenticatedUser.userId", - description = "Allow if no delegation or user delegating to themselves" - ), - AbacRuleExampleJsonV600( - category = "User - Delegation", - title = "Check Different User Delegation", - code = "onBehalfOfUserOpt.forall(_.userId != authenticatedUser.userId)", - description = "Check that delegation is to a different user (if present)" - ), - AbacRuleExampleJsonV600( - category = "User Attributes - Delegation", - title = "Check Delegation Level", - code = "onBehalfOfUserAttributes.exists(attr => attr.name == \"delegation_level\" && attr.value == \"full\")", - description = "Check if delegation has full permission level" - ), - AbacRuleExampleJsonV600( - category = "User Attributes - Delegation", - title = "Check Authorized Delegation", - code = "onBehalfOfUserAttributes.isEmpty || onBehalfOfUserAttributes.exists(_.name == \"authorized\")", - description = "Allow if no delegation attributes or has authorized attribute" - ), - AbacRuleExampleJsonV600( - category = "User - Target User", - title = "Check Self Access", - code = "userOpt.isDefined && userOpt.get.userId == authenticatedUser.userId", - description = "Check if target user is the authenticated user (self-access)" - ), - AbacRuleExampleJsonV600( - category = "User - Target User", - title = "Check Target User Provider", - code = "userOpt.exists(_.provider == \"obp\")", - description = "Check if target user is authenticated via OBP provider" - ), - AbacRuleExampleJsonV600( - category = "User - Target User", - title = "Check Target User Email Domain", - code = "userOpt.exists(_.emailAddress.endsWith(\"@trusted.com\"))", - description = "Check if target user belongs to trusted domain" - ), - AbacRuleExampleJsonV600( - category = "User - Target User", - title = "Check Target User Active", - code = "userOpt.forall(!_.isDeleted.getOrElse(false))", - description = "Ensure target user is not deleted (if present)" - ), - AbacRuleExampleJsonV600( - category = "User Attributes - Target User", - title = "Check Premium Account", - code = "userAttributes.exists(attr => attr.name == \"account_type\" && attr.value == \"premium\")", - description = "Check if target user has premium account type" - ), - AbacRuleExampleJsonV600( - category = "User Attributes - Target User", - title = "Check KYC Status", - code = "userAttributes.exists(attr => attr.name == \"kyc_status\" && attr.value == \"verified\")", - description = "Check if target user has verified KYC status" - ), - AbacRuleExampleJsonV600( - category = "User Attributes - Target User", - title = "Check User Tier Level", - code = "userAttributes.find(_.name == \"tier\").exists(_.value.toInt >= 2)", - description = "Check if user tier is 2 or higher" - ), - AbacRuleExampleJsonV600( - category = "Bank", - title = "Check Specific Bank ID", - code = "bankOpt.isDefined && bankOpt.get.bankId.value == \"gh.29.uk\"", - description = "Check if bank context is defined and matches specific bank ID" - ), - AbacRuleExampleJsonV600( - category = "Bank", - title = "Check Bank Name Contains Text", - code = "bankOpt.exists(_.fullName.contains(\"Community\"))", - description = "Check if bank full name contains specific text" - ), - AbacRuleExampleJsonV600( - category = "Bank", - title = "Check Bank Has HTTPS Website", - code = "bankOpt.exists(_.websiteUrl.contains(\"https://\"))", - description = "Check if bank website uses HTTPS" - ), - AbacRuleExampleJsonV600( - category = "Bank Attributes", - title = "Check Bank Region", - code = "bankAttributes.exists(attr => attr.name == \"region\" && attr.value == \"EU\")", - description = "Check if bank is in EU region" - ), - AbacRuleExampleJsonV600( - category = "Bank Attributes", - title = "Check Bank Certification", - code = "bankAttributes.exists(attr => attr.name == \"certified\" && attr.value == \"true\")", - description = "Check if bank has certification attribute" - ), - AbacRuleExampleJsonV600( - category = "Account", - title = "Check Minimum Balance", - code = "accountOpt.isDefined && accountOpt.get.balance > 1000", - description = "Check if account balance is above threshold" - ), - AbacRuleExampleJsonV600( - category = "Account", - title = "Check USD Account Balance", - code = "accountOpt.exists(acc => acc.currency == \"USD\" && acc.balance > 5000)", - description = "Check if USD account has balance above $5000" - ), - AbacRuleExampleJsonV600( - category = "Account", - title = "Check Account Type", - code = "accountOpt.exists(_.accountType == \"SAVINGS\")", - description = "Check if account is a savings account" - ), - AbacRuleExampleJsonV600( - category = "Account", - title = "Check Account Number Length", - code = "accountOpt.exists(_.number.length >= 10)", - description = "Check if account number has minimum length" - ), - AbacRuleExampleJsonV600( - category = "Account Attributes", - title = "Check Account Status", - code = "accountAttributes.exists(attr => attr.name == \"status\" && attr.value == \"active\")", - description = "Check if account has active status" - ), - AbacRuleExampleJsonV600( - category = "Account Attributes", - title = "Check Account Tier", - code = "accountAttributes.exists(attr => attr.name == \"account_tier\" && attr.value == \"gold\")", - description = "Check if account has gold tier" - ), - AbacRuleExampleJsonV600( - category = "Transaction", - title = "Check Transaction Amount Limit", - code = "transactionOpt.isDefined && transactionOpt.get.amount < 10000", - description = "Check if transaction amount is below limit" - ), - AbacRuleExampleJsonV600( - category = "Transaction", - title = "Check Transaction Type", - code = "transactionOpt.exists(_.transactionType.contains(\"TRANSFER\"))", - description = "Check if transaction is a transfer type" - ), - AbacRuleExampleJsonV600( - category = "Transaction", - title = "Check EUR Transaction Amount", - code = "transactionOpt.exists(t => t.currency == \"EUR\" && t.amount > 100)", - description = "Check if EUR transaction exceeds €100" - ), - AbacRuleExampleJsonV600( - category = "Transaction", - title = "Check Positive Balance After Transaction", - code = "transactionOpt.exists(_.balance > 0)", - description = "Check if balance remains positive after transaction" - ), - AbacRuleExampleJsonV600( - category = "Transaction Attributes", - title = "Check Transaction Category", - code = "transactionAttributes.exists(attr => attr.name == \"category\" && attr.value == \"business\")", - description = "Check if transaction is categorized as business" - ), - AbacRuleExampleJsonV600( - category = "Transaction Attributes", - title = "Check Transaction Not Flagged", - code = "!transactionAttributes.exists(attr => attr.name == \"flagged\" && attr.value == \"true\")", - description = "Check that transaction is not flagged" - ), - AbacRuleExampleJsonV600( - category = "Transaction Request", - title = "Check Pending Status", - code = "transactionRequestOpt.exists(_.status == \"PENDING\")", - description = "Check if transaction request is pending" - ), - AbacRuleExampleJsonV600( - category = "Transaction Request", - title = "Check SEPA Type", - code = "transactionRequestOpt.exists(_.type == \"SEPA\")", - description = "Check if transaction request is SEPA type" - ), - AbacRuleExampleJsonV600( - category = "Transaction Request", - title = "Check Same Bank", - code = "transactionRequestOpt.exists(_.this_bank_id.value == bankOpt.get.bankId.value)", - description = "Check if transaction request is for the same bank (unsafe - use exists)" - ), - AbacRuleExampleJsonV600( - category = "Transaction Request Attributes", - title = "Check High Priority", - code = "transactionRequestAttributes.exists(attr => attr.name == \"priority\" && attr.value == \"high\")", - description = "Check if transaction request has high priority" - ), - AbacRuleExampleJsonV600( - category = "Transaction Request Attributes", - title = "Check Mobile App Source", - code = "transactionRequestAttributes.exists(attr => attr.name == \"source\" && attr.value == \"mobile_app\")", - description = "Check if transaction request originated from mobile app" - ), - AbacRuleExampleJsonV600( - category = "Customer", - title = "Check Corporate Customer", - code = "customerOpt.exists(_.legalName.contains(\"Corp\"))", - description = "Check if customer legal name contains Corp" - ), - AbacRuleExampleJsonV600( - category = "Customer", - title = "Check Customer Email Matches User", - code = "customerOpt.isDefined && customerOpt.get.email == authenticatedUser.emailAddress", - description = "Check if customer email matches authenticated user" - ), - AbacRuleExampleJsonV600( - category = "Customer", - title = "Check Active Customer Relationship", - code = "customerOpt.exists(_.relationshipStatus == \"ACTIVE\")", - description = "Check if customer relationship is active" - ), - AbacRuleExampleJsonV600( - category = "Customer", - title = "Check Customer Has Mobile", - code = "customerOpt.exists(_.mobileNumber.nonEmpty)", - description = "Check if customer has mobile number on file" - ), - AbacRuleExampleJsonV600( - category = "Customer Attributes", - title = "Check Low Risk Customer", - code = "customerAttributes.exists(attr => attr.name == \"risk_level\" && attr.value == \"low\")", - description = "Check if customer has low risk level" - ), - AbacRuleExampleJsonV600( - category = "Customer Attributes", - title = "Check VIP Status", - code = "customerAttributes.exists(attr => attr.name == \"vip_status\" && attr.value == \"true\")", - description = "Check if customer has VIP status" - ), - AbacRuleExampleJsonV600( - category = "Call Context", - title = "Check Internal Network", - code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"192.168\")))", - description = "Check if request comes from internal network" - ), - AbacRuleExampleJsonV600( - category = "Call Context", - title = "Check GET Request", - code = "callContext.exists(_.verb.exists(_ == \"GET\"))", - description = "Check if request is GET method" - ), - AbacRuleExampleJsonV600( - category = "Call Context", - title = "Check URL Contains Pattern", - code = "callContext.exists(_.url.exists(_.contains(\"/accounts/\")))", - description = "Check if request URL contains accounts path" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - User Comparisons", - title = "Self Access Check", - code = "userOpt.exists(_.userId == authenticatedUser.userId)", - description = "Check if target user is the authenticated user" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - User Comparisons", - title = "Same Email Check", - code = "userOpt.exists(_.emailAddress == authenticatedUser.emailAddress)", - description = "Check if target user has same email as authenticated user" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - User Comparisons", - title = "Same Email Domain", - code = "userOpt.exists(u => authenticatedUser.emailAddress.split(\"@\")(1) == u.emailAddress.split(\"@\")(1))", - description = "Check if both users belong to same email domain" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - User Comparisons", - title = "Delegation Match", - code = "onBehalfOfUserOpt.isDefined && userOpt.isDefined && onBehalfOfUserOpt.get.userId == userOpt.get.userId", - description = "Check if delegation user matches target user" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - User Comparisons", - title = "Different User Access", - code = "userOpt.exists(_.userId != authenticatedUser.userId)", - description = "Check if accessing a different user's data" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Customer Comparisons", - title = "Customer Email Matches Auth User", - code = "customerOpt.exists(_.email == authenticatedUser.emailAddress)", - description = "Check if customer email matches authenticated user" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Customer Comparisons", - title = "Customer Email Matches Target User", - code = "customerOpt.isDefined && userOpt.isDefined && customerOpt.get.email == userOpt.get.emailAddress", - description = "Check if customer email matches target user email" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Customer Comparisons", - title = "Customer Name Contains User Name", - code = "customerOpt.exists(c => userOpt.exists(u => c.legalName.contains(u.name)))", - description = "Check if customer legal name contains user name" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Account/Transaction", - title = "Transaction Within Balance", - code = "transactionOpt.isDefined && accountOpt.isDefined && transactionOpt.get.amount < accountOpt.get.balance", - description = "Check if transaction amount is less than account balance" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Account/Transaction", - title = "Transaction Within 50% Balance", - code = "transactionOpt.exists(t => accountOpt.exists(a => t.amount <= a.balance * 0.5))", - description = "Check if transaction is within 50% of account balance" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Account/Transaction", - title = "Same Currency Check", - code = "transactionOpt.exists(t => accountOpt.exists(a => t.currency == a.currency))", - description = "Check if transaction and account have same currency" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Account/Transaction", - title = "Sufficient Funds After Transaction", - code = "transactionOpt.exists(t => accountOpt.exists(a => a.balance - t.amount >= 0))", - description = "Check if account will have sufficient funds after transaction" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Account/Transaction", - title = "Debit from Checking", - code = "transactionOpt.exists(t => accountOpt.exists(a => (a.accountType == \"CHECKING\" && t.transactionType.exists(_.contains(\"DEBIT\")))))", - description = "Check if debit transaction from checking account" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Bank/Account", - title = "Account Belongs to Bank", - code = "accountOpt.isDefined && bankOpt.isDefined && accountOpt.get.bankId == bankOpt.get.bankId.value", - description = "Check if account belongs to the bank" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Bank/Account", - title = "Account Currency Matches Bank Currency", - code = "accountOpt.exists(a => bankAttributes.exists(attr => attr.name == \"primary_currency\" && attr.value == a.currency))", - description = "Check if account currency matches bank's primary currency" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Transaction Request", - title = "Transaction Request for Account", - code = "transactionRequestOpt.exists(tr => accountOpt.exists(a => tr.this_account_id.value == a.accountId.value))", - description = "Check if transaction request is for this account" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Transaction Request", - title = "Transaction Request for Bank", - code = "transactionRequestOpt.exists(tr => bankOpt.exists(b => tr.this_bank_id.value == b.bankId.value))", - description = "Check if transaction request is for this bank" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Transaction Request", - title = "Transaction Amount Matches Charge", - code = "transactionOpt.isDefined && transactionRequestOpt.isDefined && transactionOpt.get.amount == transactionRequestOpt.get.charge.value.toDouble", - description = "Check if transaction amount matches request charge" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Attribute Comparisons", - title = "User and Account Same Tier", - code = "userAttributes.exists(ua => ua.name == \"tier\" && accountAttributes.exists(aa => aa.name == \"tier\" && ua.value == aa.value))", - description = "Check if user tier matches account tier" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Attribute Comparisons", - title = "Customer and Account Same Segment", - code = "customerAttributes.exists(ca => ca.name == \"segment\" && accountAttributes.exists(aa => aa.name == \"segment\" && ca.value == aa.value))", - description = "Check if customer segment matches account segment" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Attribute Comparisons", - title = "Auth User and Account Same Department", - code = "authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value))", - description = "Check if authenticated user department matches account department" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Attribute Comparisons", - title = "Transaction Risk Within User Tolerance", - code = "transactionAttributes.exists(ta => ta.name == \"risk_score\" && userAttributes.exists(ua => ua.name == \"risk_tolerance\" && ta.value.toInt <= ua.value.toInt))", - description = "Check if transaction risk is within user's tolerance" - ), - AbacRuleExampleJsonV600( - category = "Cross-Object - Attribute Comparisons", - title = "Bank and Customer Same Region", - code = "bankAttributes.exists(ba => ba.name == \"region\" && customerAttributes.exists(ca => ca.name == \"region\" && ba.value == ca.value))", - description = "Check if bank and customer are in same region" - ), - AbacRuleExampleJsonV600( - category = "Complex - Multi-Object", - title = "Bank Employee with Active Account", - code = "authenticatedUser.emailAddress.endsWith(\"@bank.com\") && accountOpt.exists(_.balance > 0) && bankOpt.exists(_.bankId.value == \"gh.29.uk\")", - description = "Check if bank employee accessing active account at specific bank" - ), - AbacRuleExampleJsonV600( - category = "Complex - Multi-Object", - title = "Manager Accessing Other User", - code = "authenticatedUserAttributes.exists(_.name == \"role\" && _.value == \"manager\") && userOpt.exists(_.userId != authenticatedUser.userId)", - description = "Check if manager is accessing another user's data" - ), - AbacRuleExampleJsonV600( - category = "Complex - Multi-Object", - title = "Self or Authorized Delegation with Balance", - code = "(onBehalfOfUserOpt.isEmpty || onBehalfOfUserOpt.get.userId == authenticatedUser.userId) && accountOpt.exists(_.balance > 1000)", - description = "Check if self-access or authorized delegation with minimum balance" - ), - AbacRuleExampleJsonV600( - category = "Complex - Multi-Object", - title = "Verified User with Optional Delegation", - code = "userAttributes.exists(_.name == \"kyc_status\" && _.value == \"verified\") && (onBehalfOfUserOpt.isEmpty || onBehalfOfUserAttributes.exists(_.name == \"authorized\"))", - description = "Check if user is KYC verified and delegation is authorized (if present)" - ), - AbacRuleExampleJsonV600( - category = "Complex - Multi-Object", - title = "VIP Customer with Premium Account", - code = "customerAttributes.exists(_.name == \"vip_status\" && _.value == \"true\") && accountAttributes.exists(_.name == \"account_tier\" && _.value == \"premium\")", - description = "Check if VIP customer has premium account" - ), - AbacRuleExampleJsonV600( - category = "Complex - Chained Validation", - title = "User-Customer-Account-Transaction Chain", - code = "userOpt.exists(u => customerOpt.exists(c => c.email == u.emailAddress && accountOpt.exists(a => transactionOpt.exists(t => t.accountId.value == a.accountId.value))))", - description = "Validate complete chain from user to customer to account to transaction" - ), - AbacRuleExampleJsonV600( - category = "Complex - Chained Validation", - title = "Bank-Account-Transaction Request Chain", - code = "bankOpt.exists(b => accountOpt.exists(a => a.bankId == b.bankId.value && transactionRequestOpt.exists(tr => tr.this_account_id.value == a.accountId.value)))", - description = "Validate bank owns account and transaction request is for that account" - ), - AbacRuleExampleJsonV600( - category = "Complex - Aggregation", - title = "Matching Attributes", - code = "authenticatedUserAttributes.exists(aua => userAttributes.exists(ua => aua.name == ua.name && aua.value == ua.value))", - description = "Check if authenticated user and target user share any matching attributes" - ), - AbacRuleExampleJsonV600( - category = "Complex - Aggregation", - title = "Allowed Transaction Attributes", - code = "transactionAttributes.forall(ta => accountAttributes.exists(aa => aa.name == \"allowed_transaction_\" + ta.name))", - description = "Check if all transaction attributes are allowed for this account" - ), - AbacRuleExampleJsonV600( - category = "Business Logic - Loan Approval", - title = "Credit Score and Balance Check", - code = "customerAttributes.exists(ca => ca.name == \"credit_score\" && ca.value.toInt > 650) && accountOpt.exists(_.balance > 5000)", - description = "Check if customer has good credit score and sufficient balance for loan" - ), - AbacRuleExampleJsonV600( - category = "Business Logic - Wire Transfer", - title = "Wire Transfer Authorization", - code = "transactionOpt.exists(t => t.amount < 100000 && t.transactionType.exists(_.contains(\"WIRE\"))) && authenticatedUserAttributes.exists(_.name == \"wire_authorized\")", - description = "Check if wire transfer is under limit and user is authorized" - ), - AbacRuleExampleJsonV600( - category = "Business Logic - Account Closure", - title = "Self-Service or Manager Account Closure", - code = "accountOpt.exists(a => (a.balance == 0 && userOpt.exists(_.userId == authenticatedUser.userId)) || authenticatedUserAttributes.exists(_.name == \"role\" && _.value == \"manager\"))", - description = "Allow account closure if zero balance self-service or manager override" - ), - AbacRuleExampleJsonV600( - category = "Business Logic - VIP Processing", - title = "VIP Priority Check", - code = "(customerAttributes.exists(_.name == \"vip_status\" && _.value == \"true\") || accountAttributes.exists(_.name == \"account_tier\" && _.value == \"platinum\"))", - description = "Check if customer or account qualifies for VIP priority processing" - ), - AbacRuleExampleJsonV600( - category = "Business Logic - Joint Account", - title = "Joint Account Access", - code = "accountOpt.exists(a => a.accountHolders.exists(h => h.userId == authenticatedUser.userId || h.emailAddress == authenticatedUser.emailAddress))", - description = "Check if authenticated user is one of the account holders" + category = "Access Control - Risk-Based Transaction Review", + title = "Fraud Analyst High-Risk Transaction Access", + code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"fraud_analyst\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", + description = "Allow fraud analysts to GET high-risk (score ≥75) non-completed transactions" ) ), available_operators = List( From 63b46b77f2a11ed2e52d30bf801eee68fa40d521 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Jan 2026 14:33:26 +0100 Subject: [PATCH 29/37] Docfix: Fewer ABA examples part 2 --- .../scala/code/api/v6_0_0/APIMethods600.scala | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 40ab1fc7d..42ce7e368 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5247,23 +5247,11 @@ trait APIMethods600 { code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"compliance_officer\") && transactionOpt.exists(_.amount > 10000)", description = "Allow compliance officers on internal network to review high-value transactions over 10,000" ), - AbacRuleExampleJsonV600( - category = "Access Control - Customer Data", - title = "Regional Manager Customer Access via Mobile", - code = "authenticatedUserAttributes.exists(a => a.name == \"region\" && customerAttributes.exists(ca => ca.name == \"region\" && a.value == ca.value)) && callContext.exists(_.userAgent.exists(_.contains(\"Mobile\"))) && customerOpt.exists(_.relationshipStatus == \"ACTIVE\")", - description = "Allow regional managers to access active customers in their region when using mobile app" - ), - AbacRuleExampleJsonV600( - category = "Access Control - Transaction Modification", - title = "Authorized Delegation Transaction Update", - code = "onBehalfOfUserOpt.exists(_.userId != authenticatedUser.userId) && onBehalfOfUserAttributes.exists(a => a.name == \"delegation_level\" && a.value == \"full\") && callContext.exists(_.verb.exists(_ == \"PUT\")) && transactionOpt.exists(t => t.amount < 5000)", - description = "Allow full delegation to update transactions under 5000 via PUT requests" - ), AbacRuleExampleJsonV600( category = "Access Control - Account Balance", - title = "Department Head Same-Department Account Read", - code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"department_head\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance > 0)", - description = "Allow department heads to read account details for accounts in their department with positive balance" + title = "Department Head Same-Department Account Read where overdrawn", + code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"department_head\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", + description = "Allow department heads to read account details for overdrawn accounts in their department" ), AbacRuleExampleJsonV600( category = "Access Control - Transaction Request Approval", From 9576a5ccc780f6aa0c3c272acef3ced469839d9e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Jan 2026 16:38:08 +0100 Subject: [PATCH 30/37] Feature: ABAC has entitlements for auth user and obo user --- .../scala/code/abacrule/AbacRuleEngine.scala | 27 ++++++++++--- .../scala/code/api/v6_0_0/APIMethods600.scala | 40 +++++++++++-------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 5b531af98..8b8dc2cad 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -4,6 +4,7 @@ import code.api.util.{APIUtil, CallContext, DynamicUtil} import code.bankconnectors.Connector import code.model.dataAccess.ResourceUser import code.users.Users +import code.entitlement.Entitlement import com.openbankproject.commons.model._ import com.openbankproject.commons.ExecutionContext.Implicits.global import net.liftweb.common.{Box, Empty, Failure, Full} @@ -26,12 +27,12 @@ object AbacRuleEngine { /** * Type alias for compiled ABAC rule function - * Parameters: authenticatedUser (logged in), authenticatedUserAttributes (non-personal), authenticatedUserAuthContext (auth context), - * onBehalfOfUser (delegation), onBehalfOfUserAttributes, onBehalfOfUserAuthContext, + * Parameters: authenticatedUser (logged in), authenticatedUserAttributes (non-personal), authenticatedUserAuthContext (auth context), authenticatedUserEntitlements (roles), + * onBehalfOfUser (delegation), onBehalfOfUserAttributes, onBehalfOfUserAuthContext, onBehalfOfUserEntitlements, * user, userAttributes, bankOpt, bankAttributes, accountOpt, accountAttributes, transactionOpt, transactionAttributes, customerOpt, customerAttributes * Returns: Boolean (true = allow access, false = deny access) */ - type AbacRuleFunction = (User, List[UserAttributeTrait], List[UserAuthContext], Option[User], List[UserAttributeTrait], List[UserAuthContext], Option[User], List[UserAttributeTrait], Option[Bank], List[BankAttributeTrait], Option[BankAccount], List[AccountAttribute], Option[Transaction], List[TransactionAttribute], Option[TransactionRequest], List[TransactionRequestAttributeTrait], Option[Customer], List[CustomerAttribute], Option[CallContext]) => Boolean + type AbacRuleFunction = (User, List[UserAttributeTrait], List[UserAuthContext], List[Entitlement], Option[User], List[UserAttributeTrait], List[UserAuthContext], List[Entitlement], Option[User], List[UserAttributeTrait], Option[Bank], List[BankAttributeTrait], Option[BankAccount], List[AccountAttribute], Option[Transaction], List[TransactionAttribute], Option[TransactionRequest], List[TransactionRequestAttributeTrait], Option[Customer], List[CustomerAttribute], Option[CallContext]) => Boolean /** * Compile an ABAC rule from Scala code @@ -75,7 +76,7 @@ object AbacRuleEngine { |import net.liftweb.common._ | |// ABAC Rule Function - |(authenticatedUser: User, authenticatedUserAttributes: List[UserAttributeTrait], authenticatedUserAuthContext: List[UserAuthContext], onBehalfOfUserOpt: Option[User], onBehalfOfUserAttributes: List[UserAttributeTrait], onBehalfOfUserAuthContext: List[UserAuthContext], userOpt: Option[User], userAttributes: List[UserAttributeTrait], bankOpt: Option[Bank], bankAttributes: List[BankAttributeTrait], accountOpt: Option[BankAccount], accountAttributes: List[AccountAttribute], transactionOpt: Option[Transaction], transactionAttributes: List[TransactionAttribute], transactionRequestOpt: Option[TransactionRequest], transactionRequestAttributes: List[TransactionRequestAttributeTrait], customerOpt: Option[Customer], customerAttributes: List[CustomerAttribute], callContext: Option[code.api.util.CallContext]) => { + |(authenticatedUser: User, authenticatedUserAttributes: List[UserAttributeTrait], authenticatedUserAuthContext: List[UserAuthContext], authenticatedUserEntitlements: List[Entitlement], onBehalfOfUserOpt: Option[User], onBehalfOfUserAttributes: List[UserAttributeTrait], onBehalfOfUserAuthContext: List[UserAuthContext], onBehalfOfUserEntitlements: List[Entitlement], userOpt: Option[User], userAttributes: List[UserAttributeTrait], bankOpt: Option[Bank], bankAttributes: List[BankAttributeTrait], accountOpt: Option[BankAccount], accountAttributes: List[AccountAttribute], transactionOpt: Option[Transaction], transactionAttributes: List[TransactionAttribute], transactionRequestOpt: Option[TransactionRequest], transactionRequestAttributes: List[TransactionRequestAttributeTrait], customerOpt: Option[Customer], customerAttributes: List[CustomerAttribute], callContext: Option[code.api.util.CallContext]) => { | $ruleCode |} |""".stripMargin @@ -129,6 +130,12 @@ object AbacRuleEngine { 5.seconds ) + // Fetch entitlements for authenticated user + authenticatedUserEntitlements = Await.result( + code.api.util.NewStyle.function.getEntitlementsByUserId(authenticatedUserId, Some(callContext)), + 5.seconds + ) + // Fetch onBehalfOf user if provided (delegation scenario) onBehalfOfUserOpt <- onBehalfOfUserId match { case Some(obUserId) => Users.users.vend.getUserByUserId(obUserId).map(Some(_)) @@ -155,6 +162,16 @@ object AbacRuleEngine { case None => List.empty[UserAuthContext] } + // Fetch entitlements for onBehalfOf user if provided + onBehalfOfUserEntitlements = onBehalfOfUserId match { + case Some(obUserId) => + Await.result( + code.api.util.NewStyle.function.getEntitlementsByUserId(obUserId, Some(callContext)), + 5.seconds + ) + case None => List.empty[Entitlement] + } + // Fetch target user if userId is provided userOpt <- userId match { case Some(uId) => Users.users.vend.getUserByUserId(uId).map(Some(_)) @@ -274,7 +291,7 @@ object AbacRuleEngine { // Compile and execute the rule compiledFunc <- compileRule(ruleId, rule.ruleCode) result <- tryo { - compiledFunc(authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, onBehalfOfUserOpt, onBehalfOfUserAttributes, onBehalfOfUserAuthContext, userOpt, userAttributes, bankOpt, bankAttributes, accountOpt, accountAttributes, transactionOpt, transactionAttributes, transactionRequestOpt, transactionRequestAttributes, customerOpt, customerAttributes, Some(callContext)) + compiledFunc(authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, authenticatedUserEntitlements, onBehalfOfUserOpt, onBehalfOfUserAttributes, onBehalfOfUserAuthContext, onBehalfOfUserEntitlements, userOpt, userAttributes, bankOpt, bankAttributes, accountOpt, accountAttributes, transactionOpt, transactionAttributes, transactionRequestOpt, transactionRequestAttributes, customerOpt, customerAttributes, Some(callContext)) } } yield result } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 42ce7e368..c7dbfadfd 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5116,9 +5116,11 @@ trait APIMethods600 { AbacParameterJsonV600("authenticatedUser", "User", "The logged-in user (always present)", required = true, "User"), AbacParameterJsonV600("authenticatedUserAttributes", "List[UserAttributeTrait]", "Non-personal attributes of authenticated user", required = true, "User"), AbacParameterJsonV600("authenticatedUserAuthContext", "List[UserAuthContext]", "Auth context of authenticated user", required = true, "User"), + AbacParameterJsonV600("authenticatedUserEntitlements", "List[Entitlement]", "Entitlements (roles) of authenticated user", required = true, "User"), AbacParameterJsonV600("onBehalfOfUserOpt", "Option[User]", "User being acted on behalf of (delegation)", required = false, "User"), AbacParameterJsonV600("onBehalfOfUserAttributes", "List[UserAttributeTrait]", "Attributes of delegation user", required = false, "User"), AbacParameterJsonV600("onBehalfOfUserAuthContext", "List[UserAuthContext]", "Auth context of delegation user", required = false, "User"), + AbacParameterJsonV600("onBehalfOfUserEntitlements", "List[Entitlement]", "Entitlements (roles) of delegation user", required = false, "User"), AbacParameterJsonV600("userOpt", "Option[User]", "Target user being evaluated", required = false, "User"), AbacParameterJsonV600("userAttributes", "List[UserAttributeTrait]", "Attributes of target user", required = false, "User"), AbacParameterJsonV600("bankOpt", "Option[Bank]", "Bank context", required = false, "Bank"), @@ -5223,6 +5225,12 @@ trait APIMethods600 { AbacObjectPropertyJsonV600("value", "String", "Attribute value"), AbacObjectPropertyJsonV600("attributeType", "AttributeType", "Attribute type") )), + AbacObjectTypeJsonV600("Entitlement", "User entitlement (role)", List( + AbacObjectPropertyJsonV600("entitlementId", "String", "Entitlement ID"), + AbacObjectPropertyJsonV600("roleName", "String", "Role name (e.g., CanCreateAccount, CanReadTransactions)"), + AbacObjectPropertyJsonV600("bankId", "String", "Bank ID (empty string for system-wide roles)"), + AbacObjectPropertyJsonV600("userId", "String", "User ID this entitlement belongs to") + )), AbacObjectTypeJsonV600("CallContext", "Request context with metadata", List( AbacObjectPropertyJsonV600("correlationId", "String", "Correlation ID for request tracking"), AbacObjectPropertyJsonV600("url", "Option[String]", "Request URL"), @@ -5238,50 +5246,50 @@ trait APIMethods600 { AbacRuleExampleJsonV600( category = "Access Control - Account Access", title = "Branch Manager Internal Account Access", - code = "authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", - description = "Allow GET access to current accounts only when user's branch matches account's branch" + code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", + description = "Allow GET access to current accounts when user has CanReadAccountsAtOneBank role and branch matches account's branch" ), AbacRuleExampleJsonV600( category = "Access Control - Transaction Access", title = "Internal Network High-Value Transaction Review", - code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"compliance_officer\") && transactionOpt.exists(_.amount > 10000)", - description = "Allow compliance officers on internal network to review high-value transactions over 10,000" + code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && transactionOpt.exists(_.amount > 10000)", + description = "Allow users with CanReadTransactionsAtOneBank role on internal network to review high-value transactions over 10,000" ), AbacRuleExampleJsonV600( category = "Access Control - Account Balance", title = "Department Head Same-Department Account Read where overdrawn", - code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"department_head\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", - description = "Allow department heads to read account details for overdrawn accounts in their department" + code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", + description = "Allow users with CanReadAccountsAtOneBank role to read overdrawn accounts in their department" ), AbacRuleExampleJsonV600( category = "Access Control - Transaction Request Approval", title = "Manager Internal Network Transaction Approval", - code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && List(\"manager\", \"supervisor\").contains(a.value)) && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", - description = "Allow managers/supervisors on internal network to approve pending transaction requests under 50,000" + code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateTransactionRequest\") && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", + description = "Allow users with CanCreateTransactionRequest role on internal network to approve pending transaction requests under 50,000" ), AbacRuleExampleJsonV600( category = "Access Control - Customer Onboarding", title = "KYC Officer Customer Creation from Branch", - code = "authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", - description = "Allow KYC certified officers to create customers via POST from branch network (10.20.x.x) when status is pending" + code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateCustomer\") && authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", + description = "Allow users with CanCreateCustomer role and KYC certification to create customers via POST from branch network (10.20.x.x) when status is pending" ), AbacRuleExampleJsonV600( category = "Access Control - Cross-Border Transaction", title = "International Team Foreign Currency Transaction", - code = "authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", - description = "Allow international team to access foreign currency transactions under 100k on international-enabled accounts" + code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", + description = "Allow international team users with CanReadTransactionsAtOneBank role to access foreign currency transactions under 100k on international-enabled accounts" ), AbacRuleExampleJsonV600( category = "Access Control - Delegated Account Management", title = "Assistant with Limited Delegation Account View", - code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserAttributes.exists(a => a.name == \"role\" && a.value == \"executive\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", - description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of their assigned executive" + code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", + description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of a user with CanReadAccountsAtOneBank role" ), AbacRuleExampleJsonV600( category = "Access Control - Risk-Based Transaction Review", title = "Fraud Analyst High-Risk Transaction Access", - code = "authenticatedUserAttributes.exists(a => a.name == \"role\" && a.value == \"fraud_analyst\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", - description = "Allow fraud analysts to GET high-risk (score ≥75) non-completed transactions" + code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", + description = "Allow users with CanReadTransactionsAtOneBank role to GET high-risk (score ≥75) non-completed transactions" ) ), available_operators = List( From d95189e36fa1249939cedc26ea7bbb3f5041859b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Tue, 13 Jan 2026 19:07:23 +0100 Subject: [PATCH 31/37] Aligning ABAC examples with actual field names --- .../scala/code/api/v6_0_0/APIMethods600.scala | 80 +++++++++---------- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 8 +- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index c7dbfadfd..ef4f6337b 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5076,16 +5076,16 @@ trait APIMethods600 { ), examples = List( AbacRuleExampleJsonV600( - category = "User Access", - title = "Check User Identity", - code = "authenticatedUser.userId == user.userId", - description = "Verify that the authenticated user matches the target user" + rule_name = "Check User Identity", + rule_code = "authenticatedUser.userId == user.userId", + description = "Verify that the authenticated user matches the target user", + is_active = true ), AbacRuleExampleJsonV600( - category = "Bank Access", - title = "Check Specific Bank", - code = "bankOpt.isDefined && bankOpt.get.bankId.value == \"gh.29.uk\"", - description = "Verify that the bank context is defined and matches a specific bank ID" + rule_name = "Check Specific Bank", + rule_code = "bankOpt.isDefined && bankOpt.get.bankId.value == \"gh.29.uk\"", + description = "Verify that the bank context is defined and matches a specific bank ID", + is_active = true ) ), available_operators = List("==", "!=", "&&", "||", "!", ">", "<", ">=", "<=", "contains", "isDefined"), @@ -5244,52 +5244,52 @@ trait APIMethods600 { ), examples = List( AbacRuleExampleJsonV600( - category = "Access Control - Account Access", - title = "Branch Manager Internal Account Access", - code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", - description = "Allow GET access to current accounts when user has CanReadAccountsAtOneBank role and branch matches account's branch" + rule_name = "Branch Manager Internal Account Access", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", + description = "Allow GET access to current accounts when user has CanReadAccountsAtOneBank role and branch matches account's branch", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Transaction Access", - title = "Internal Network High-Value Transaction Review", - code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && transactionOpt.exists(_.amount > 10000)", - description = "Allow users with CanReadTransactionsAtOneBank role on internal network to review high-value transactions over 10,000" + rule_name = "Internal Network High-Value Transaction Review", + rule_code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && transactionOpt.exists(_.amount > 10000)", + description = "Allow users with CanReadTransactionsAtOneBank role on internal network to review high-value transactions over 10,000", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Account Balance", - title = "Department Head Same-Department Account Read where overdrawn", - code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", - description = "Allow users with CanReadAccountsAtOneBank role to read overdrawn accounts in their department" + rule_name = "Department Head Same-Department Account Read where overdrawn", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", + description = "Allow users with CanReadAccountsAtOneBank role to read overdrawn accounts in their department", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Transaction Request Approval", - title = "Manager Internal Network Transaction Approval", - code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateTransactionRequest\") && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", - description = "Allow users with CanCreateTransactionRequest role on internal network to approve pending transaction requests under 50,000" + rule_name = "Manager Internal Network Transaction Approval", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateTransactionRequest\") && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", + description = "Allow users with CanCreateTransactionRequest role on internal network to approve pending transaction requests under 50,000", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Customer Onboarding", - title = "KYC Officer Customer Creation from Branch", - code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateCustomer\") && authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", - description = "Allow users with CanCreateCustomer role and KYC certification to create customers via POST from branch network (10.20.x.x) when status is pending" + rule_name = "KYC Officer Customer Creation from Branch", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateCustomer\") && authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", + description = "Allow users with CanCreateCustomer role and KYC certification to create customers via POST from branch network (10.20.x.x) when status is pending", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Cross-Border Transaction", - title = "International Team Foreign Currency Transaction", - code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", - description = "Allow international team users with CanReadTransactionsAtOneBank role to access foreign currency transactions under 100k on international-enabled accounts" + rule_name = "International Team Foreign Currency Transaction", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", + description = "Allow international team users with CanReadTransactionsAtOneBank role to access foreign currency transactions under 100k on international-enabled accounts", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Delegated Account Management", - title = "Assistant with Limited Delegation Account View", - code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", - description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of a user with CanReadAccountsAtOneBank role" + rule_name = "Assistant with Limited Delegation Account View", + rule_code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", + description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of a user with CanReadAccountsAtOneBank role", + is_active = true ), AbacRuleExampleJsonV600( - category = "Access Control - Risk-Based Transaction Review", - title = "Fraud Analyst High-Risk Transaction Access", - code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", - description = "Allow users with CanReadTransactionsAtOneBank role to GET high-risk (score ≥75) non-completed transactions" + rule_name = "Fraud Analyst High-Risk Transaction Access", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", + description = "Allow users with CanReadTransactionsAtOneBank role to GET high-risk (score ≥75) non-completed transactions", + is_active = true ) ), available_operators = List( diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 36ab2d96b..7e64eec36 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -459,10 +459,10 @@ case class AbacObjectTypeJsonV600( ) case class AbacRuleExampleJsonV600( - category: String, - title: String, - code: String, - description: String + rule_name: String, + rule_code: String, + description: String, + is_active: Boolean ) case class AbacRuleSchemaJsonV600( From f95e8b8645d9bd52261cb14a799cc3ba59e21288 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 14 Jan 2026 09:32:23 +0100 Subject: [PATCH 32/37] ABAC Policy instead of tag --- .../scala/code/abacrule/AbacRuleEngine.scala | 64 ++++++++ .../scala/code/abacrule/AbacRuleTrait.scala | 23 +++ .../scala/code/api/constant/constant.scala | 13 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 142 +++++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 14 ++ 5 files changed, 255 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 8b8dc2cad..2145c7831 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -298,6 +298,70 @@ object AbacRuleEngine { + /** + * Execute all active ABAC rules with a specific policy (OR logic - at least one must pass) + * @param logic The logic to apply: "AND" (all must pass), "OR" (any must pass), "XOR" (exactly one must pass) + * + * @param policy The policy to filter rules by + * @param authenticatedUserId The ID of the authenticated user + * @param onBehalfOfUserId Optional ID of user being acted on behalf of + * @param userId The ID of the target user to evaluate + * @param callContext Call context for fetching objects + * @param bankId Optional bank ID + * @param accountId Optional account ID + * @param viewId Optional view ID + * @param transactionId Optional transaction ID + * @param transactionRequestId Optional transaction request ID + * @param customerId Optional customer ID + * @return Box[Boolean] - Full(true) if at least one rule passes (OR logic), Full(false) if all fail + */ + def executeRulesByPolicy( + policy: String, + authenticatedUserId: String, + onBehalfOfUserId: Option[String] = None, + userId: Option[String] = None, + callContext: CallContext, + bankId: Option[String] = None, + accountId: Option[String] = None, + viewId: Option[String] = None, + transactionId: Option[String] = None, + transactionRequestId: Option[String] = None, + customerId: Option[String] = None + ): Box[Boolean] = { + val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy) + + if (rules.isEmpty) { + // No rules for this policy - default to allow + Full(true) + } else { + // Execute all rules and check if at least one passes + val results = rules.map { rule => + executeRule( + ruleId = rule.abacRuleId, + authenticatedUserId = authenticatedUserId, + onBehalfOfUserId = onBehalfOfUserId, + userId = userId, + callContext = callContext, + bankId = bankId, + accountId = accountId, + viewId = viewId, + transactionId = transactionId, + transactionRequestId = transactionRequestId, + customerId = customerId + ) + } + + // Count successes and failures + val successes = results.filter { + case Full(true) => true + case _ => false + } + + // At least one rule must pass (OR logic) + Full(successes.nonEmpty) + } + } + /** * Validate ABAC rule code by attempting to compile it * diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala index e4309f342..9e9a22885 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala @@ -14,6 +14,7 @@ trait AbacRuleTrait { def ruleCode: String def isActive: Boolean def description: String + def policy: String def createdByUserId: String def updatedByUserId: String } @@ -30,6 +31,7 @@ class AbacRule extends AbacRuleTrait with LongKeyedMapper[AbacRule] with IdPK wi override def defaultValue = true } object Description extends MappedText(this) + object Policy extends MappedText(this) object CreatedByUserId extends MappedString(this, 255) object UpdatedByUserId extends MappedString(this, 255) @@ -38,6 +40,7 @@ class AbacRule extends AbacRuleTrait with LongKeyedMapper[AbacRule] with IdPK wi override def ruleCode: String = RuleCode.get override def isActive: Boolean = IsActive.get override def description: String = Description.get + override def policy: String = Policy.get override def createdByUserId: String = CreatedByUserId.get override def updatedByUserId: String = UpdatedByUserId.get } @@ -51,10 +54,13 @@ trait AbacRuleProvider { def getAbacRuleByName(ruleName: String): Box[AbacRuleTrait] def getAllAbacRules(): List[AbacRuleTrait] def getActiveAbacRules(): List[AbacRuleTrait] + def getAbacRulesByPolicy(policy: String): List[AbacRuleTrait] + def getActiveAbacRulesByPolicy(policy: String): List[AbacRuleTrait] def createAbacRule( ruleName: String, ruleCode: String, description: String, + policy: String, isActive: Boolean, createdBy: String ): Box[AbacRuleTrait] @@ -63,6 +69,7 @@ trait AbacRuleProvider { ruleName: String, ruleCode: String, description: String, + policy: String, isActive: Boolean, updatedBy: String ): Box[AbacRuleTrait] @@ -87,10 +94,23 @@ object MappedAbacRuleProvider extends AbacRuleProvider { AbacRule.findAll(By(AbacRule.IsActive, true)) } + override def getAbacRulesByPolicy(policy: String): List[AbacRuleTrait] = { + AbacRule.findAll().filter { rule => + rule.policy.split(",").map(_.trim).contains(policy) + } + } + + override def getActiveAbacRulesByPolicy(policy: String): List[AbacRuleTrait] = { + AbacRule.findAll(By(AbacRule.IsActive, true)).filter { rule => + rule.policy.split(",").map(_.trim).contains(policy) + } + } + override def createAbacRule( ruleName: String, ruleCode: String, description: String, + policy: String, isActive: Boolean, createdBy: String ): Box[AbacRuleTrait] = { @@ -99,6 +119,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider { .RuleName(ruleName) .RuleCode(ruleCode) .Description(description) + .Policy(policy) .IsActive(isActive) .CreatedByUserId(createdBy) .UpdatedByUserId(createdBy) @@ -111,6 +132,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider { ruleName: String, ruleCode: String, description: String, + policy: String, isActive: Boolean, updatedBy: String ): Box[AbacRuleTrait] = { @@ -121,6 +143,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider { .RuleName(ruleName) .RuleCode(ruleCode) .Description(description) + .Policy(policy) .IsActive(isActive) .UpdatedByUserId(updatedBy) .saveMe() diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 73cee00a6..9816ad4a2 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -266,6 +266,19 @@ object Constant extends MdcLoggable { // ABAC Cache Prefixes (with global namespace and versioning) def ABAC_RULE_PREFIX: String = getVersionedCachePrefix(ABAC_RULE_NAMESPACE) + // ABAC Policy Constants + final val ABAC_POLICY_ACCOUNT_ACCESS = "account-access" + + // List of all ABAC Policies + final val ABAC_POLICIES: List[String] = List( + ABAC_POLICY_ACCOUNT_ACCESS + ) + + // Map of ABAC Policies to their descriptions + final val ABAC_POLICY_DESCRIPTIONS: Map[String, String] = Map( + ABAC_POLICY_ACCOUNT_ACCESS -> "Rules for controlling access to account information and account-related operations" + ) + final val CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT = "can_see_transaction_other_bank_account" final val CAN_SEE_TRANSACTION_METADATA = "can_see_transaction_metadata" final val CAN_SEE_TRANSACTION_DESCRIPTION = "can_see_transaction_description" diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index ef4f6337b..677811329 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -27,7 +27,7 @@ import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500} import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson} -import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600, AbacPoliciesJsonV600, AbacPolicyJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics @@ -4732,6 +4732,7 @@ trait APIMethods600 { rule_name = "admin_only", rule_code = """user.emailAddress.contains("admin")""", description = "Only allow access to users with admin email", + policy = "user-access,admin", is_active = true ), AbacRuleJsonV600( @@ -4740,6 +4741,7 @@ trait APIMethods600 { rule_code = """user.emailAddress.contains("admin")""", is_active = true, description = "Only allow access to users with admin email", + policy = "user-access,admin", created_by_user_id = "user123", updated_by_user_id = "user123" ), @@ -4779,6 +4781,7 @@ trait APIMethods600 { ruleName = createJson.rule_name, ruleCode = createJson.rule_code, description = createJson.description, + policy = createJson.policy, isActive = createJson.is_active, createdBy = user.userId ) @@ -4815,6 +4818,7 @@ trait APIMethods600 { rule_code = """user.emailAddress.contains("admin")""", is_active = true, description = "Only allow access to users with admin email", + policy = "user-access,admin", created_by_user_id = "user123", updated_by_user_id = "user123" ), @@ -4870,6 +4874,7 @@ trait APIMethods600 { rule_code = """user.emailAddress.contains("admin")""", is_active = true, description = "Only allow access to users with admin email", + policy = "user-access,admin", created_by_user_id = "user123", updated_by_user_id = "user123" ) @@ -4899,6 +4904,75 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getAbacRulesByPolicy, + implementedInApiVersion, + nameOf(getAbacRulesByPolicy), + "GET", + "/management/abac-rules/policy/POLICY", + "Get ABAC Rules by Policy", + s"""Get all ABAC rules that belong to a specific policy. + | + |Multiple rules can share the same policy. Rules with multiple policies (comma-separated) + |will be returned if any of their policies match the requested policy. + | + |**Documentation:** + |- ${Glossary.getGlossaryItemLink("ABAC_Simple_Guide")} - Getting started with ABAC rules + |- ${Glossary.getGlossaryItemLink("ABAC_Parameters_Summary")} - Complete list of all 18 parameters + |- ${Glossary.getGlossaryItemLink("ABAC_Object_Properties_Reference")} - Detailed property reference + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + AbacRulesJsonV600( + abac_rules = List( + AbacRuleJsonV600( + abac_rule_id = "abc123", + rule_name = "admin_only", + rule_code = """user.emailAddress.contains("admin")""", + is_active = true, + description = "Only allow access to users with admin email", + policy = "user-access,admin", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ), + AbacRuleJsonV600( + abac_rule_id = "def456", + rule_name = "admin_department_check", + rule_code = """user.department == "admin"""", + is_active = true, + description = "Check if user is in admin department", + policy = "admin", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ) + ) + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canGetAbacRule)) + ) + + lazy val getAbacRulesByPolicy: OBPEndpoint = { + case "management" :: "abac-rules" :: "policy" :: policy :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) + rules <- Future { + MappedAbacRuleProvider.getAbacRulesByPolicy(policy) + } + } yield { + (createAbacRulesJsonV600(rules), HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( updateAbacRule, implementedInApiVersion, @@ -4920,6 +4994,7 @@ trait APIMethods600 { rule_name = "admin_only_updated", rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", description = "Only allow access to OBP admin users", + policy = "user-access,admin,obp", is_active = true ), AbacRuleJsonV600( @@ -4928,6 +5003,7 @@ trait APIMethods600 { rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", is_active = true, description = "Only allow access to OBP admin users", + policy = "user-access,admin,obp", created_by_user_id = "user123", updated_by_user_id = "user456" ), @@ -4962,6 +5038,7 @@ trait APIMethods600 { ruleName = updateJson.rule_name, ruleCode = updateJson.rule_code, description = updateJson.description, + policy = updateJson.policy, isActive = updateJson.is_active, updatedBy = user.userId ) @@ -5079,11 +5156,13 @@ trait APIMethods600 { rule_name = "Check User Identity", rule_code = "authenticatedUser.userId == user.userId", description = "Verify that the authenticated user matches the target user", + policy = "user-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "Check Specific Bank", rule_code = "bankOpt.isDefined && bankOpt.get.bankId.value == \"gh.29.uk\"", + policy = "bank-access", description = "Verify that the bank context is defined and matches a specific bank ID", is_active = true ) @@ -5247,48 +5326,56 @@ trait APIMethods600 { rule_name = "Branch Manager Internal Account Access", rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", description = "Allow GET access to current accounts when user has CanReadAccountsAtOneBank role and branch matches account's branch", + policy = "account-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "Internal Network High-Value Transaction Review", rule_code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && transactionOpt.exists(_.amount > 10000)", description = "Allow users with CanReadTransactionsAtOneBank role on internal network to review high-value transactions over 10,000", + policy = "transaction-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "Department Head Same-Department Account Read where overdrawn", rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", description = "Allow users with CanReadAccountsAtOneBank role to read overdrawn accounts in their department", + policy = "account-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "Manager Internal Network Transaction Approval", rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateTransactionRequest\") && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", description = "Allow users with CanCreateTransactionRequest role on internal network to approve pending transaction requests under 50,000", + policy = "transaction-request", is_active = true ), AbacRuleExampleJsonV600( rule_name = "KYC Officer Customer Creation from Branch", rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateCustomer\") && authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", description = "Allow users with CanCreateCustomer role and KYC certification to create customers via POST from branch network (10.20.x.x) when status is pending", + policy = "customer-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "International Team Foreign Currency Transaction", rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", description = "Allow international team users with CanReadTransactionsAtOneBank role to access foreign currency transactions under 100k on international-enabled accounts", + policy = "transaction-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "Assistant with Limited Delegation Account View", rule_code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of a user with CanReadAccountsAtOneBank role", + policy = "account-access", is_active = true ), AbacRuleExampleJsonV600( rule_name = "Fraud Analyst High-Risk Transaction Access", rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", description = "Allow users with CanReadTransactionsAtOneBank role to GET high-risk (score ≥75) non-completed transactions", + policy = "transaction-access", is_active = true ) ), @@ -5315,6 +5402,59 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getAbacPolicies, + implementedInApiVersion, + nameOf(getAbacPolicies), + "GET", + "/management/abac-policies", + "Get ABAC Policies", + s"""Get the list of allowed ABAC policy names. + | + |ABAC rules are organized by policies. Each rule must have at least one policy assigned. + |Rules can have multiple policies (comma-separated). This endpoint returns the list of + |standardized policy names that should be used when creating or updating rules. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + AbacPoliciesJsonV600( + policies = List( + AbacPolicyJsonV600( + policy = "account-access", + description = "Rules for controlling access to account information" + ) + ) + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canGetAbacRule)) + ) + + lazy val getAbacPolicies: OBPEndpoint = { + case "management" :: "abac-policies" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) + } yield { + val policies = Constant.ABAC_POLICIES.map { policy => + AbacPolicyJsonV600( + policy = policy, + description = Constant.ABAC_POLICY_DESCRIPTIONS.getOrElse(policy, "No description available") + ) + } + + (AbacPoliciesJsonV600(policies), HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( validateAbacRule, implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 7e64eec36..55a92ef0f 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -380,6 +380,7 @@ case class CreateAbacRuleJsonV600( rule_name: String, rule_code: String, description: String, + policy: String, is_active: Boolean ) @@ -387,6 +388,7 @@ case class UpdateAbacRuleJsonV600( rule_name: String, rule_code: String, description: String, + policy: String, is_active: Boolean ) @@ -396,6 +398,7 @@ case class AbacRuleJsonV600( rule_code: String, is_active: Boolean, description: String, + policy: String, created_by_user_id: String, updated_by_user_id: String ) @@ -462,6 +465,7 @@ case class AbacRuleExampleJsonV600( rule_name: String, rule_code: String, description: String, + policy: String, is_active: Boolean ) @@ -473,6 +477,15 @@ case class AbacRuleSchemaJsonV600( notes: List[String] ) +case class AbacPolicyJsonV600( + policy: String, + description: String +) + +case class AbacPoliciesJsonV600( + policies: List[AbacPolicyJsonV600] +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( @@ -1086,6 +1099,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { rule_code = rule.ruleCode, is_active = rule.isActive, description = rule.description, + policy = rule.policy, created_by_user_id = rule.createdByUserId, updated_by_user_id = rule.updatedByUserId ) From 9eb984306635f9044c9b6f1323a98081e4edad3b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 14 Jan 2026 09:55:02 +0100 Subject: [PATCH 33/37] Execute Policy endpoint --- .../scala/code/api/v6_0_0/APIMethods600.scala | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 677811329..d7a601178 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5652,6 +5652,112 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + executeAbacPolicy, + implementedInApiVersion, + nameOf(executeAbacPolicy), + "POST", + "/management/abac-policies/POLICY/execute", + "Execute ABAC Policy", + s"""Execute all ABAC rules in a policy to test access control. + | + |This endpoint executes all active rules that belong to the specified policy. + |The policy uses OR logic - access is granted if at least one rule passes. + | + |This allows you to test a complete policy with specific context (authenticated user, bank, account, transaction, customer, etc.). + | + |**Documentation:** + |- ${Glossary.getGlossaryItemLink("ABAC_Simple_Guide")} - Getting started with ABAC rules + |- ${Glossary.getGlossaryItemLink("ABAC_Parameters_Summary")} - Complete list of all 18 parameters + |- ${Glossary.getGlossaryItemLink("ABAC_Object_Properties_Reference")} - Detailed property reference + |- ${Glossary.getGlossaryItemLink("ABAC_Testing_Examples")} - Testing examples and patterns + | + |You can provide optional IDs in the request body to test the policy with specific context. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + ExecuteAbacRuleJsonV600( + authenticated_user_id = Some("c7b6cb47-cb96-4441-8801-35b57456753a"), + on_behalf_of_user_id = Some("a3b5c123-1234-5678-9012-fedcba987654"), + user_id = Some("c7b6cb47-cb96-4441-8801-35b57456753a"), + bank_id = Some("gh.29.uk"), + account_id = Some("8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0"), + view_id = Some("owner"), + transaction_request_id = Some("123456"), + transaction_id = Some("abc123"), + customer_id = Some("customer-id-123") + ), + AbacRuleResultJsonV600( + result = true + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canExecuteAbacRule)) + ) + + lazy val executeAbacPolicy: OBPEndpoint = { + case "management" :: "abac-policies" :: policy :: "execute" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canExecuteAbacRule, callContext) + execJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { + json.extract[ExecuteAbacRuleJsonV600] + } + + // Verify the policy exists + _ <- Future { + if (Constant.ABAC_POLICIES.contains(policy)) { + Full(true) + } else { + Failure(s"Policy not found: $policy. Available policies: ${Constant.ABAC_POLICIES.mkString(", ")}") + } + } map { + unboxFullOrFail(_, callContext, s"Invalid ABAC Policy: $policy", 404) + } + + // Execute the policy with IDs - object fetching happens internally + // authenticatedUserId: can be provided in request (for testing) or defaults to actual authenticated user + // onBehalfOfUserId: optional delegation - acting on behalf of another user + // userId: the target user being evaluated (defaults to authenticated user) + effectiveAuthenticatedUserId = execJson.authenticated_user_id.getOrElse(user.userId) + + result <- Future { + val resultBox = AbacRuleEngine.executeRulesByPolicy( + policy = policy, + authenticatedUserId = effectiveAuthenticatedUserId, + onBehalfOfUserId = execJson.on_behalf_of_user_id, + userId = execJson.user_id, + callContext = callContext.getOrElse(cc), + bankId = execJson.bank_id, + accountId = execJson.account_id, + viewId = execJson.view_id, + transactionId = execJson.transaction_id, + transactionRequestId = execJson.transaction_request_id, + customerId = execJson.customer_id + ) + + resultBox match { + case Full(allowed) => + AbacRuleResultJsonV600(result = allowed) + case Failure(msg, _, _) => + AbacRuleResultJsonV600(result = false) + case Empty => + AbacRuleResultJsonV600(result = false) + } + } + } yield { + (result, HttpCode.`200`(callContext)) + } + } + } + // ============================================================================================================ // USER ATTRIBUTES v6.0.0 - Consistent with other entity attributes // ============================================================================================================ From ae599cef454767ab86ff8b39448992903d9eeb24 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 14 Jan 2026 13:30:33 +0100 Subject: [PATCH 34/37] JKS endpoint tagged OAuth and OIDC --- obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala | 2 ++ obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 2145c7831..93fb81537 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -74,6 +74,8 @@ object AbacRuleEngine { |import com.openbankproject.commons.model._ |import code.model.dataAccess.ResourceUser |import net.liftweb.common._ + |import code.entitlement.Entitlement + |import code.api.util.CallContext | |// ABAC Rule Function |(authenticatedUser: User, authenticatedUserAttributes: List[UserAttributeTrait], authenticatedUserAuthContext: List[UserAuthContext], authenticatedUserEntitlements: List[Entitlement], onBehalfOfUserOpt: Option[User], onBehalfOfUserAttributes: List[UserAttributeTrait], onBehalfOfUserAuthContext: List[UserAuthContext], onBehalfOfUserEntitlements: List[Entitlement], userOpt: Option[User], userAttributes: List[UserAttributeTrait], bankOpt: Option[Bank], bankAttributes: List[BankAttributeTrait], accountOpt: Option[BankAccount], accountAttributes: List[AccountAttribute], transactionOpt: Option[Transaction], transactionAttributes: List[TransactionAttribute], transactionRequestOpt: Option[TransactionRequest], transactionRequestAttributes: List[TransactionRequestAttributeTrait], customerOpt: Option[Customer], customerAttributes: List[CustomerAttribute], callContext: Option[code.api.util.CallContext]) => { diff --git a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala index b88d88b49..c4867e5d8 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala @@ -1863,7 +1863,7 @@ trait APIMethods310 { List( UnknownError ), - List(apiTagApi)) + List(apiTagApi, apiTagOAuth, apiTagOIDC)) lazy val getObpConnectorLoopback : OBPEndpoint = { case "connector" :: "loopback" :: Nil JsonGet _ => { @@ -4111,7 +4111,7 @@ trait APIMethods310 { List( UnknownError ), - List(apiTagApi)) + List(apiTagApi, apiTagOAuth, apiTagOIDC)) lazy val getOAuth2ServerJWKsURIs: OBPEndpoint = { case "jwks-uris" :: Nil JsonGet _ => { From 423c0c17bd8cc9a1dd093bbbaf54cafbb596a1d4 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 14 Jan 2026 13:44:02 +0100 Subject: [PATCH 35/37] JKS endpoint tagged OAuth and OIDC 2 adding tags - and adding SuperAdmin Entitlement --- obp-api/src/main/scala/code/api/util/ApiTag.scala | 2 ++ .../main/scala/code/api/v6_0_0/APIMethods600.scala | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index bd4c41f01..38208d32d 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -18,6 +18,8 @@ object ApiTag { val apiTagTransactionRequestAttribute = ResourceDocTag("Transaction-Request-Attribute") val apiTagVrp = ResourceDocTag("VRP") val apiTagApi = ResourceDocTag("API") + val apiTagOAuth = ResourceDocTag("OAuth") + val apiTagOIDC = ResourceDocTag("OIDC") val apiTagBank = ResourceDocTag("Bank") val apiTagBankAttribute = ResourceDocTag("Bank-Attribute") val apiTagAccount = ResourceDocTag("Account") diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index d7a601178..38abee8f3 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1074,7 +1074,15 @@ trait APIMethods600 { entitlements <- NewStyle.function.getEntitlementsByUserId(u.userId, callContext) } yield { val permissions: Option[Permission] = Views.views.vend.getPermissionForUser(u).toOption - val currentUser = UserV600(u, entitlements, permissions) + // Add SuperAdmin virtual entitlement if user is super admin + // NOTE: We ONLY use this Role in order to create CanCreateEntitlementAtAnyBank and also delete. + // Thus it is a boot straping Role. Useful to have in response so the API Manager shows Create Entitlement page to the User. + val finalEntitlements = if (APIUtil.isSuperAdmin(u.userId)) { + entitlements ::: List(Entitlement.entitlement.vend.addEntitlement("", u.userId, "SuperAdmin")) + } else { + entitlements + } + val currentUser = UserV600(u, finalEntitlements, permissions) val onBehalfOfUser = if(cc.onBehalfOfUser.isDefined) { val user = cc.onBehalfOfUser.toOption.get val entitlements = Entitlement.entitlement.vend.getEntitlementsByUserId(user.userId).headOption.toList.flatten @@ -5449,7 +5457,7 @@ trait APIMethods600 { description = Constant.ABAC_POLICY_DESCRIPTIONS.getOrElse(policy, "No description available") ) } - + (AbacPoliciesJsonV600(policies), HttpCode.`200`(callContext)) } } From 439423fc4d212c928498321581dc02af2543b2f5 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 14 Jan 2026 14:08:15 +0100 Subject: [PATCH 36/37] Adding SuperAdmin Entitlement 2 --- .../scala/code/api/v6_0_0/APIMethods600.scala | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 38abee8f3..9dd79e45c 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1075,10 +1075,19 @@ trait APIMethods600 { } yield { val permissions: Option[Permission] = Views.views.vend.getPermissionForUser(u).toOption // Add SuperAdmin virtual entitlement if user is super admin - // NOTE: We ONLY use this Role in order to create CanCreateEntitlementAtAnyBank and also delete. - // Thus it is a boot straping Role. Useful to have in response so the API Manager shows Create Entitlement page to the User. val finalEntitlements = if (APIUtil.isSuperAdmin(u.userId)) { - entitlements ::: List(Entitlement.entitlement.vend.addEntitlement("", u.userId, "SuperAdmin")) + // Create a virtual SuperAdmin entitlement + val superAdminEntitlement: Entitlement = new Entitlement { + def entitlementId: String = "" + def bankId: String = "" + def userId: String = u.userId + def roleName: String = "SuperAdmin" + def createdByProcess: String = "System" + def entitlementRequestId: Option[String] = None + def groupId: Option[String] = None + def process: Option[String] = None + } + entitlements ::: List(superAdminEntitlement) } else { entitlements } From 3a264ed32676adbd2a761a6125cb2b09f120e61e Mon Sep 17 00:00:00 2001 From: karmaking Date: Wed, 14 Jan 2026 14:44:19 +0100 Subject: [PATCH 37/37] fix Github Action --- .github/workflows/auto_update_base_image.yml | 35 ------ .../build_container_develop_branch.yml | 31 ----- .../build_container_non_develop_branch.yml | 114 ------------------ .github/workflows/build_pull_request.yml | 87 ------------- .github/workflows/run_trivy.yml | 54 --------- 5 files changed, 321 deletions(-) delete mode 100644 .github/workflows/auto_update_base_image.yml delete mode 100644 .github/workflows/build_container_non_develop_branch.yml delete mode 100644 .github/workflows/build_pull_request.yml delete mode 100644 .github/workflows/run_trivy.yml diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml deleted file mode 100644 index 3048faf15..000000000 --- a/.github/workflows/auto_update_base_image.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Regular base image update check -on: - schedule: - - cron: "0 5 * * *" - workflow_dispatch: - -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Docker Image Update Checker - id: baseupdatecheck - uses: lucacome/docker-image-update-checker@v2.0.0 - with: - base-image: jetty:9.4-jdk11-alpine - image: ${{ env.DOCKER_HUB_ORGANIZATION }}/obp-api:latest - - - name: Trigger build_container_develop_branch workflow - uses: actions/github-script@v6 - with: - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'build_container_develop_branch.yml', - ref: 'refs/heads/develop' - }); - if: steps.baseupdatecheck.outputs.needs-updating == 'true' diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index d3f355042..793a4d81e 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -86,34 +86,3 @@ jobs: with: name: ${{ github.sha }} path: push/ - - - name: Build the Docker image - run: | - echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop - docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags - echo docker done - - - uses: sigstore/cosign-installer@main - - - name: Write signing key to disk (only needed for `cosign sign --key`) - run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - - - name: Sign container image - run: | - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC - env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - - diff --git a/.github/workflows/build_container_non_develop_branch.yml b/.github/workflows/build_container_non_develop_branch.yml deleted file mode 100644 index 946d81de4..000000000 --- a/.github/workflows/build_container_non_develop_branch.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Build and publish container non develop - -on: - push: - branches: - - '*' - - '!develop' - -env: - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - DOCKER_HUB_REPOSITORY: obp-api - -jobs: - build: - runs-on: ubuntu-latest - services: - # Label used to access the service container - redis: - # Docker Hub image - image: redis - ports: - # Opens tcp port 6379 on the host and service container - - 6379:6379 - # Set health checks to wait until redis has started - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" - - name: Set up JDK 11 - uses: actions/setup-java@v4 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - name: Build with Maven - run: | - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props - echo connector=star > obp-api/src/main/resources/props/test.default.props - echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props - echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props - echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props - echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props - echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props - echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props - echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props - echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props - echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props - echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props - echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod - - - name: Save .war artifact - run: | - mkdir -p ./push - cp obp-api/target/obp-api-1.*.war ./push/ - - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: push/ - - - name: Build the Docker image - run: | - echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} - docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags - echo docker done - - - uses: sigstore/cosign-installer@main - - - name: Write signing key to disk (only needed for `cosign sign --key`) - run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - - - name: Sign container image - run: | - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA - env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - - diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml deleted file mode 100644 index 859d309ec..000000000 --- a/.github/workflows/build_pull_request.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Build on Pull Request - -on: - pull_request: - branches: - - '**' -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - - -jobs: - build: - runs-on: ubuntu-latest - services: - # Label used to access the service container - redis: - # Docker Hub image - image: redis - ports: - # Opens tcp port 6379 on the host and service container - - 6379:6379 - # Set health checks to wait until redis has started - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 11 - uses: actions/setup-java@v4 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - name: Build with Maven - run: | - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props - echo connector=star > obp-api/src/main/resources/props/test.default.props - echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props - echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props - echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props - echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props - echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props - echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props - echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props - echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props - echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props - echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - - - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props - echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod - - - name: Save .war artifact - run: | - mkdir -p ./pull - cp obp-api/target/obp-api-1.*.war ./pull/ - - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: pull/ - - - diff --git a/.github/workflows/run_trivy.yml b/.github/workflows/run_trivy.yml deleted file mode 100644 index 4636bd311..000000000 --- a/.github/workflows/run_trivy.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: scan container image - -on: - workflow_run: - workflows: - - Build and publish container develop - - Build and publish container non develop - types: - - completed -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - DOCKER_HUB_REPOSITORY: obp-api - - -jobs: - build: - runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} - - steps: - - uses: actions/checkout@v4 - - id: trivy-db - name: Check trivy db sha - env: - GH_TOKEN: ${{ github.token }} - run: | - endpoint='/orgs/aquasecurity/packages/container/trivy-db/versions' - headers='Accept: application/vnd.github+json' - jqFilter='.[] | select(.metadata.container.tags[] | contains("latest")) | .name | sub("sha256:";"")' - sha=$(gh api -H "${headers}" "${endpoint}" | jq --raw-output "${jqFilter}") - echo "Trivy DB sha256:${sha}" - echo "::set-output name=sha::${sha}" - - uses: actions/cache@v4 - with: - path: .trivy - key: ${{ runner.os }}-trivy-db-${{ steps.trivy-db.outputs.sha }} - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: 'docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}' - format: 'template' - template: '@/contrib/sarif.tpl' - output: 'trivy-results.sarif' - security-checks: 'vuln' - severity: 'CRITICAL,HIGH' - timeout: '30m' - cache-dir: .trivy - - name: Fix .trivy permissions - run: sudo chown -R $(stat . -c %u:%g) .trivy - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: 'trivy-results.sarif' \ No newline at end of file